Last active
July 2, 2024 06:45
-
-
Save juliarose/9a15c33f30af35938c3475266ce19bb5 to your computer and use it in GitHub Desktop.
Efficient color utility functions.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @ts-check | |
/** | |
* Lightens a color. | |
* @param {string} color - Hexadecimal number string of color. | |
* @param {number} [ratio=0.5] - Strength of effect. | |
* @returns {(string | null)} 6-digit hexadecimal number string of result. | |
*/ | |
export function lighten(color, ratio = 0.5) { | |
ratio = clampRatio(ratio); | |
const channels = hexToRgb(color); | |
if (channels === null) { | |
return null; | |
} | |
const [r, g, b] = channels; | |
const [h, s, l] = rgbToHSL(r, g, b); | |
// increase lightness | |
const lightenedlightness = l + (100 - l) * ratio; | |
const lightenedChannels = hslToRGB(h, s, lightenedlightness); | |
let result = ''; | |
for (let i = 0; i < 3; i++) { | |
result += decimalToHex(lightenedChannels[i]); | |
} | |
return result; | |
} | |
/** | |
* Darkens a color. | |
* @param {string} color - Hexadecimal number string of color. | |
* @param {number} [ratio=0.5] - Strength of effect. | |
* @returns {(string | null)} 6-digit hexadecimal number string of result. | |
*/ | |
export function darken(color, ratio = 0.5) { | |
ratio = clampRatio(ratio); | |
const channels = hexToRgb(color); | |
if (channels === null) { | |
return null; | |
} | |
let result = ''; | |
for (let i = 0; i < 3; i++) { | |
const calc = channels[i] * (1 - ratio); | |
const decimal = toUint8Saturating(calc); | |
const hex = decimalToHex(decimal); | |
result += hex; | |
} | |
return result; | |
} | |
/** | |
* Blends two colors. | |
* @param {string} color1 - Hexadecimal number string of first color. | |
* @param {string} color2 - Hexadecimal number string of second color. | |
* @param {number} [ratio=0.5] - Strength of effect. | |
* @returns {(string | null)} 6-digit hexadecimal number string of result. | |
*/ | |
export function blend(color1, color2, ratio = 0.5) { | |
ratio = clampRatio(ratio); | |
const channels1 = hexToRgb(color1); | |
const channels2 = hexToRgb(color2); | |
if (channels1 === null || channels2 === null) { | |
return null; | |
} | |
let result = ''; | |
for (let i = 0; i < 3; i++) { | |
const calc1 = channels1[i] * (1 - ratio); | |
const calc2 = channels2[i] * ratio; | |
const decimal = toUint8Saturating(calc1 + calc2); | |
const hex = decimalToHex(decimal); | |
result += hex; | |
} | |
return result; | |
} | |
/** | |
* Converts color to rgba string for use in CSS. | |
* @param {string} color - Hexadecimal number string. | |
* @param {number} [alpha=1] - Opacity of color. | |
* @returns {(string | null)} Rgba string of color. | |
*/ | |
export function rgba(color, alpha = 1) { | |
alpha = clampRatio(alpha); | |
const results = hexToRgb(color); | |
if (results === null) { | |
return null; | |
} | |
// add value for alpha | |
results.push(alpha); | |
return `rgba(${results.join(' ')})`; | |
} | |
/** | |
* Gets the lightness of a color. | |
* @param {string} color - Hexadecimal number string. | |
* @returns {(number | null)} Lightness of color. | |
*/ | |
export function lightness(color) { | |
// get lightness using | |
// https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-tests | |
const channels = hexToRgb(color); | |
if (channels === null) { | |
return null; | |
} | |
const values = [ | |
0.2126, | |
0.7152, | |
0.0722 | |
]; | |
const min = 0.03928; | |
let result = 0; | |
for (let i = 0; i < 3; i++) { | |
const ratio = channels[i] / 255; | |
const value = values[i]; | |
let calc = ratio; | |
if (calc <= min) { | |
calc /= 12.92; | |
} else { | |
calc = Math.pow((calc + 0.055) / 1.055, 2.4); | |
} | |
calc *= value; | |
result += calc; | |
} | |
return result; | |
} | |
/** | |
* Converts RGB color to HSL color. | |
* @param {number} red - Red color channel. | |
* @param {number} green - Green color channel. | |
* @param {number} blue - Blue color channel. | |
* @returns {number[]} HSL color channels. | |
* | |
* @example | |
* rgbToHSL(0, 255, 0); // [120, 100, 50] | |
*/ | |
export function rgbToHSL(red, green, blue) { | |
// https://stackoverflow.com/questions/46432335/hex-to-hsl-convert-javascript | |
red = red / 255; | |
green = green / 255; | |
blue = blue / 255; | |
const max = Math.max(red, green, blue); | |
const min = Math.min(red, green, blue); | |
const average = (max + min) / 2; | |
let hue = average; | |
let saturation = average; | |
let lightness = average; | |
if (max === min){ | |
hue = saturation = 0; // achromatic | |
} else { | |
const difference = max - min; | |
if (lightness > 0.5) { | |
saturation = difference / (2 - max - min); | |
} else { | |
saturation = difference / (max + min); | |
} | |
switch (max) { | |
case red: | |
hue = ((green - blue) / difference) + green < blue ? 6 : 0; | |
break; | |
case green: | |
hue = ((blue - red) / difference) + 2; | |
break; | |
case blue: | |
hue = ((red - green) / difference) + 4; | |
break; | |
} | |
hue = hue / 6; | |
} | |
saturation = Math.round(saturation * 100); | |
lightness = Math.round(lightness * 100); | |
hue = Math.round(360 * hue); | |
return [ | |
hue, | |
saturation, | |
lightness | |
]; | |
} | |
/** | |
* Converts HSL color to RGB color. | |
* @param {number} hue - Hue value. | |
* @param {number} saturation - Saturation value. | |
* @param {number} lightness - Lightness value. | |
* @returns {number[]} RGB color channels. | |
* | |
* @example | |
* hslToRGB(120, 100, 50); // [0, 255, 0] | |
*/ | |
export function hslToRGB(hue, saturation, lightness) { | |
hue = hue / 360; | |
saturation = saturation / 100; | |
lightness = lightness / 100; | |
let red, green, blue; | |
if (saturation === 0) { | |
red = green = blue = lightness; // achromatic | |
} else { | |
const hueToRGB = (p, q, t) => { | |
if (t < 0) { | |
t += 1; | |
} | |
if (t > 1) { | |
t -= 1; | |
} | |
if (t < 1 / 6) { | |
return p + (q - p) * 6 * t; | |
} | |
if (t < 1 / 2) { | |
return q; | |
} | |
if (t < 2 / 3) { | |
return p + (q - p) * (2 / 3 - t) * 6; | |
} | |
return p; | |
}; | |
const q = lightness < 0.5 ? | |
lightness * (1 + saturation) : | |
(lightness + saturation) - (lightness * saturation); | |
const p = 2 * lightness - q; | |
red = hueToRGB(p, q, hue + 1 / 3); | |
green = hueToRGB(p, q, hue); | |
blue = hueToRGB(p, q, hue - 1 / 3); | |
} | |
return [ | |
Math.round(red * 255), | |
Math.round(green * 255), | |
Math.round(blue * 255) | |
]; | |
} | |
/** | |
* Gets individual color channels from a hexadecimal color. If an alpha channel is present, it | |
* will be included. | |
* @param {string} color - Hexadecimal number string. | |
* @returns {([number, number, number] | [number, number, number, number] | null)} Color channels. | |
* | |
* @example | |
* hexToRgb('#00FF00', 0); // [0, 255, 0] | |
* | |
* @example | |
* hexToRgb('#00FF00FF', 0); // [0, 255, 0, 255] | |
*/ | |
export function hexToRgb(color) { | |
if (color[0] === '#') { | |
// Trim the pound sign | |
color = color.slice(1); | |
} | |
const length = color.length; | |
const isValidLength = length === 3 || length === 4 || length === 6 || length === 8; | |
if (!isValidLength) { | |
return null; | |
} | |
const decimal = parseInt(color, 16); | |
switch (length) { | |
case 3: return [ | |
// Red | |
((decimal >> 8) & 0xf) * 0x11, | |
// Green | |
((decimal >> 4) & 0xf) * 0x11, | |
// Blue | |
(decimal & 0xf) * 0x11 | |
]; | |
case 4: return [ | |
// Red | |
((decimal >> 12) & 0xf) * 0x11, | |
// Green | |
((decimal >> 8) & 0xf) * 0x11, | |
// Blue | |
((decimal >> 4) & 0xf) * 0x11, | |
// Alpha | |
(decimal & 0xf) * 0x11 | |
]; | |
case 6: return [ | |
// Red | |
(decimal >> 16) & 0xff, | |
// Green | |
(decimal >> 8) & 0xff, | |
// Blue | |
decimal & 0xff | |
]; | |
case 8: return [ | |
// Red | |
(decimal >> 24) & 0xff, | |
// Green | |
(decimal >> 16) & 0xff, | |
// Blue | |
(decimal >> 8) & 0xff, | |
// Alpha | |
decimal & 0xff | |
]; | |
} | |
} | |
/** | |
* Convert a decimal number to a hexadecimal number in a 2-digit format. | |
* @param {number} decimal - Decimal number for color channel in 8-bit unsigned integer range. | |
* This must be >=0 and <=255. | |
* @returns {string} Hexadecimal number. | |
*/ | |
function decimalToHex(decimal) { | |
if (decimal < 0 || decimal > 255) { | |
throw new Error('Invalid decimal range'); | |
} | |
const n = decimal.toString(16); | |
if (n.length === 1) { | |
// must be 2 characters | |
return '0' + n; | |
} | |
return n; | |
} | |
/** | |
* Converts a number to an 8-bit unsigned integer. Saturates the value if it's out of range. | |
* @param {number} num - Number to convert. | |
* @returns {number} The number within the 8-bit unsigned integer range. | |
*/ | |
function toUint8Saturating(num) { | |
if (num < 0) { | |
return 0; | |
} else if (num > 255) { | |
return 255; | |
} | |
return Math.round(num); | |
} | |
/** | |
* Clamps a ratio to between 0 and 1. | |
* @param {number} ratio - Ratio to clamp. | |
* @returns Ratio clamped between 0 and 1. | |
*/ | |
function clampRatio(ratio) { | |
if (ratio < 0) { | |
return 0; | |
} | |
if (ratio > 1) { | |
return 1; | |
} | |
return ratio; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment