@nvl/egal
Version:
Reparametrization of OkLCh/HCT to simplify uniformity in color saturation.
386 lines (385 loc) • 15.1 kB
JavaScript
// External dependencies
import { LRUCache } from 'lru-cache';
import Color from 'colorjs.io';
import { inspect } from 'node:util';
/**
* Cache for the chroma floor calculations.
*
* - **Keys:** Format: `"lightness_hues_space_gamut_precision"`.
* - **Values:** The chroma floor for the lightness and options encoded in the
* key.
*
* @example
* ```ts
* const cache = new LRUCache<CacheKey, number>({ max: 360 * 100 });
* cache.set('50_0,123,300_hct_srgb_0.1', 10.3);
* cache.set('20_10_oklch_p3_1', 5);
* ```
*/
const cache = new LRUCache({ max: 360 * 100 });
export const ColorSpaceConstants = {
/**
* Globals set by HCT; do not modify unless changes to the HCT color system
* are made.
*/
hct: {
hue: { min: 0, max: 360 },
chroma: { min: 0, max: 200 },
lightness: { min: 0, max: 100 }, // "Tone" in HCT
bgLstar: { min: 0, max: 100 },
props: { hueProp: 'h', chromaProp: 'c', lightnessProp: 't' },
},
/**
* Globals set by OkLCh; do not modify unless changes to the OkLCh color
* system are made.
*/
oklch: {
hue: { min: 0, max: 360 },
chroma: { min: 0, max: 1 },
lightness: { min: 0, max: 1 },
props: { hueProp: 'h', chromaProp: 'c', lightnessProp: 'l' },
},
};
export const defaults = {
hueStep: 1,
gamut: 'srgb',
space: 'oklch',
precision: {
oklch: 0.00001,
hct: 0.01,
},
};
/**
* Given a specific hue and lightness, returns the maximum chroma that a color
* with those hue and lightness values can have while still remaining
* displayable in the specified color gamut (by default, `'srgb'`).
*
* @param hue - The hue of the color, in degrees. Must be between 0 and 360.
* @param lightness - The lightness of the color. Must be between 0 and 100.
* @param opts - Options for calculating the maximum chroma.
* @returns The maximum chroma that a color with the specified hue and lightness
* can have in the specified viewing conditions.
*/
export function findMaxChroma(hue, lightness, opts) {
const { precision, gamut, space } = {
...{
gamut: defaults.gamut,
space: defaults.space,
precision: defaults.precision[opts.space],
},
...opts,
};
// Binary search for the maximum chroma.
let { min, max } = ColorSpaceConstants[space].chroma;
const { hueProp, lightnessProp, chromaProp } = ColorSpaceConstants[space].props;
let err = Infinity;
const color = new Color({ space, coords: [0, 0, 0] });
color[hueProp] = hue;
color[lightnessProp] = lightness;
let chroma = 0;
// Binary search for the maximum chroma.
while (err > precision) {
chroma = (min + max) / 2;
color[chromaProp] = chroma;
if (color.inGamut(gamut)) {
min = chroma;
}
else {
max = chroma;
}
err = max - min;
}
return chroma;
}
/**
* Given a specific lightness, returns the maximum chroma that *all* colors with
* that lightness can have across the specified hues.
*
* @param lightness - The lightness for which to find the "chroma floor". Must
* be between 0 and 100, both inclusive.
* @param opts - Options for the calculation the chroma floor.
* @returns The chroma floor, i.e., the minimum of the maximum chromas for each
* hue-lightness pair.
*/
export function findChromaFloorForLightness(lightness, opts) {
const { hues, ...findChromaOptions } = opts;
const space = findChromaOptions.space;
// Prepare an array of hues to consider.
let huesArray;
if (!Array.isArray(hues)) {
// If hues is not an array, we interpret it as a step value.
huesArray = [];
const step = hues;
for (let hue = ColorSpaceConstants[space].hue.min; hue < ColorSpaceConstants[space].hue.max; hue += step) {
huesArray.push(hue);
}
}
else {
huesArray = hues;
}
// Find the minimum chroma floor.
let chromaFloor = ColorSpaceConstants[space].chroma.max;
huesArray.forEach((hue) => {
const chroma = findMaxChroma(hue, lightness, findChromaOptions);
if (chroma < chromaFloor) {
chromaFloor = chroma;
}
});
return chromaFloor;
}
export const defaultOptions = {
hues: 1,
output: 'oklch',
opacity: 1,
guardrails: true,
toeFunction: undefined,
};
/**
*
* @param lightness - The lightness of the color. Must be between 0 and 100. A
* lightness of 0 will always result in black (`rgb(0%, 0%, 0%)` or equivalent),
* and a lightness of 100 will always result in white (`rgb(100%, 100%, 100%)`
* or equivalent).
* @param chroma - The chroma of the color. Must be a nonnegative number. Values
* are interpreted as percentages of the maximal chroma _such that the chroma
* can be maintained across the specified hues_. A chroma of 0 will result in a
* grayscale color.
* @param hue - The hue of the color, in degrees. Must be between 0 and 360.
* @param options - Options for the calculation.
* @returns A string representing the color in the specified output format, such
* that the color obeys CSS syntax and remains within the specified color gamut.
*/
// - **PRE:**
// - `lightness ∈ ℝ ∪ {-∞, ∞, NaN}`
// - `chroma ∈ ℝ ∪ {-∞, ∞, NaN}`
// - `hue ∈ ℝ ∪ {-∞, ∞, NaN}`
// - **POST:** `output` is a string as described above.
export function egal(lightness, chroma, hue, options = {}) {
var _a, _b;
// Filter out options set to undefined
const filteredOptions = Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined));
// Merge options with defaults.
const mergedOptions = {
...defaultOptions,
...{
gamut: defaults.gamut,
space: defaults.space,
precision: defaults.precision[(_a = options.space) !== null && _a !== void 0 ? _a : defaults.space],
},
...filteredOptions,
};
const { output, space, gamut, toeFunction, guardrails } = mergedOptions;
let { hues, opacity, precision } = mergedOptions;
// Sanitize and process inputs.
hue = sanitizeHue(hue);
chroma = sanitizeChroma(chroma);
lightness = sanitizeLightness((_b = toeFunction === null || toeFunction === void 0 ? void 0 : toeFunction(lightness, { ...options, toeFunction: undefined })) !== null && _b !== void 0 ? _b : lightness, toeFunction !== undefined, lightness);
opacity = isNaN(opacity) ? 1 : ensureInRange(opacity, 0, 1);
if (Array.isArray(hues)) {
if (hues.length === 0) {
console.warn(`The 'hues' option should not be an empty array; using the default instead (a step value of ${defaultOptions.hues}).`);
hues = defaultOptions.hues;
}
else {
hues = hues.map(sanitizeHue).sort();
}
}
else {
hues = sanitizeHue(hues);
}
if (guardrails) {
precision = sanitizePrecision(precision, space);
if (typeof hues === 'number')
hues = sanitizeHues(hues);
}
// Set up the cache key.
const key = `${lightness}_${Array.isArray(hues) ? hues.join(',') : String(hues)}_${space}_${gamut}_${precision}`;
// Calculate the chroma floor, using cache if possible.
let chromaFloor = cache.get(key);
if (chromaFloor === undefined) {
chromaFloor = findChromaFloorForLightness(lightness, {
hues,
space,
gamut,
precision,
});
cache.set(key, chromaFloor);
}
// The chroma input should be interpreted as a fraction of the chroma floor.
// We're defining `adjustedChroma` as the resulting chroma value.
const adjustedChroma = chromaFloor * chroma;
const color = new Color(space, [0, 0, 0]);
color[space][ColorSpaceConstants[space].props.lightnessProp] = lightness;
color[space][ColorSpaceConstants[space].props.chromaProp] = adjustedChroma;
color[space][ColorSpaceConstants[space].props.hueProp] = hue;
// Set the opacity.
color.alpha = opacity;
return color
.to(output)
.toGamut({ method: 'css', jnd: 0, space: gamut })
.toString();
}
/**
* - **PRE:** `h ∈ ℝ ∪ {-∞, ∞, NaN}`
* - **POST:** If `h` is finite, then `output ∈ [0, 360)` and
* `output ≡ h mod 360`. If `h` is not finite, then `output = 0`.
*/
export function sanitizeHue(h) {
if (Number.isFinite(h)) {
if (h < 0)
return 360 + (h % 360);
return h % 360;
}
console.warn(`Invalid hue value. Expected finite number, got ${inspect(h)} instead. Reverting to 0.`);
return 0;
}
/**
* - **PRE:** `value, min, max ∈ ℝ ∪ {-∞, ∞}` and `min ≤ max`
* - **POST:** `output ∈ [min, max]`, with `output` being the closest value to
* `value` within the range `[min, max]`.
*/
export function ensureInRange(value, min, max) {
if (value < min)
return min;
if (value > max)
return max;
return value;
}
/**
* Thresholds for options and input values to use in the sanitization processes.
*/
export const Thresholds = {
precision: {
oklch: { min: 0.00001, max: 0.05 },
hct: { min: 0.001, max: 5 },
},
hues: { min: 1 },
chroma: { max: 1e6 },
lightness: { max: 100 },
};
/**
* - **PRE:** `p ∈ ℝ ∪ {-∞, ∞, NaN}`, `space ∈ {'oklch', 'hct'}`
* - **POST:** `output ∈ [Thresholds.precision[space].min, Thresholds.precision[space].max]`
*
* @param p - The precision to sanitize.
* @returns The sanitized precision.
*/
export function sanitizePrecision(p, space) {
if (!isFinite(p)) {
console.warn(`The 'precision' option should be a finite, positive number (received ${String(p)}); using the default for ${space.toUpperCase()} instead (${defaults.precision[space]}).`);
return defaults.precision[space];
}
else if (p === 0) {
console.warn(`The 'precision' option should not be set to 0; using the default for ${space.toUpperCase()} instead (${defaults.precision[space]}).`);
return defaults.precision[space];
}
else if (p < 0) {
console.warn(`The 'precision' option should not be negative (received ${p}); using the default for ${space.toUpperCase()} instead (${defaults.precision[space]}).`);
return defaults.precision[space];
}
else if (p < Thresholds.precision[space].min) {
console.warn(`The 'precision' option is very high (received ${p}); this may lead to performance issues. For example, the default precision for ${space.toUpperCase()} is ${defaults.precision[space]}. Using ${Thresholds.precision[space].min} instead. To prevent this behavior, set 'guardrails' to false.`);
return Thresholds.precision[space].min;
}
else if (p > Thresholds.precision[space].max) {
console.warn(`The 'precision' option is very low (received ${p}); this may lead to bad results. For example, the default precision for ${space.toUpperCase()} is ${defaults.precision[space]}. Using ${Thresholds.precision[space].max} instead. To prevent this behavior, set 'guardrails' to false.`);
return Thresholds.precision[space].max;
}
else {
return p;
}
}
/**
* - **PRE:** `h ∈ [0, 360)`
* - **POST:** `output ∈ [Thresholds.hues.min, 360)`
*
* @param h - The hue step value to sanitize.
* @returns The sanitized hue step value.
*/
export function sanitizeHues(h) {
if (h === 0) {
console.warn(`The 'hues' option should not be set to 0 or a multiple of 360 (received ${h}); using the default instead (${defaultOptions.hues}).`);
return defaultOptions.hues;
}
else if (h < Thresholds.hues.min) {
console.warn(`The 'hues' step is very small (received ${h}); this may lead to performance issues. For example, the default hue step is ${defaultOptions.hues}. Using ${Thresholds.hues.min} instead. To prevent this behavior, set 'guardrails' to false.`);
return Thresholds.hues.min;
}
else {
return h;
}
}
/**
* - **PRE:** `c ∈ ℝ ∪ {-∞, ∞, NaN}`
* - **POST:** `output ∈ [0, Thresholds.chroma.max]`
*
* @param c - The chroma to sanitize.
* @returns The sanitized chroma.
*/
export function sanitizeChroma(c) {
if (isNaN(c)) {
console.warn(`The chroma value should be a finite nonnegative number (received NaN); using 0 instead.`);
return 0;
}
else if (!isFinite(c)) {
const cleanChroma = c < 0 ? 0 : Thresholds.chroma.max;
console.warn(`The chroma value should be a finite nonnegative number (received ${c}); using ${cleanChroma} instead.`);
return cleanChroma;
}
else if (c < 0) {
console.warn(`The chroma value should be a nonnegative number (received ${c}); using 0 instead.`);
return 0;
}
else if (c > Thresholds.chroma.max) {
console.warn(`The chroma value should be at most ${Thresholds.chroma.max} (received ${c}); using ${Thresholds.chroma.max} instead.`);
return Thresholds.chroma.max;
}
else {
return c;
}
}
/**
* - **PRE:** `l ∈ ℝ ∪ {-∞, ∞, NaN}`
* - **POST:** `output ∈ [0, Thresholds.lightness.max]`
*
* @param l - The lightness to sanitize.
* @param toeFn - Whether a toe function was applied to get `l`.
* @param lBeforeToeFn - If `toeFn` is `true`, this is the lightness before the
* toe function was applied. If `toeFn` is `false`, this is just `l`.
* @returns The sanitized lightness.
*
* @remarks
* The `toeFn` and `lBeforeToeFn` parameters are used to provide more
* informative warning messages, but they are not otherwise used in the
* sanitization process.
*/
export function sanitizeLightness(l, toeFn, lBeforeToeFn) {
if (isNaN(l)) {
console.warn(`The lightness value should be a finite nonnegative number (received ${toeFn
? `${l} (result of applying the toe function to ${lBeforeToeFn})`
: String(l)}); using 0 instead.`);
return 0;
}
else if (!isFinite(l)) {
const cleanLightness = l < 0 ? 0 : Thresholds.lightness.max;
console.warn(`The lightness value should be a finite nonnegative number (received ${toeFn
? `${l} (result of applying the toe function to ${lBeforeToeFn})`
: String(l)}); using ${cleanLightness} instead.`);
return cleanLightness;
}
else if (l < 0) {
console.warn(`The lightness value should be a nonnegative number (received ${toeFn
? `${l} (result of applying the toe function to ${lBeforeToeFn})`
: String(l)}); using 0 instead.`);
return 0;
}
else if (l > Thresholds.lightness.max) {
console.warn(`The lightness value should be at most ${Thresholds.lightness.max} (received ${toeFn
? `${l} (result of applying the toe function to ${lBeforeToeFn})`
: String(l)}); using ${Thresholds.lightness.max} instead.`);
return Thresholds.lightness.max;
}
else {
return l;
}
}