@markgorzynski/color-utils
Version:
The only color science library with adaptive visual perception modeling and accessibility-driven optimization.
335 lines (309 loc) • 14.6 kB
JavaScript
/**
* @file src/chromaControl.js
* @module color-utils/chromaControl
* @description Provides advanced functions for controlling Oklch chroma and AOkLab Lightness
* to achieve specific target CIE Luminance (derived from CIELAB L*) values,
* while leveraging the AOkLab model for its surround adaptation and hue uniformity.
* This enables the creation of color palettes that are both perceptually harmonious
* under various viewing conditions and compliant with WCAG contrast requirements.
*
* **Key Features & Novel Approach:**
*
* This module offers a unique combination of capabilities not commonly found together:
*
* 1. **Leverages Oklab's Excellent Hue Uniformity:**
* By working with AOkLab Hue (H_aok) and Chroma (C_aok), which are based on Oklab's
* structure, this module benefits from Oklab's superior hue linearity compared to
* spaces like CIELAB (especially in blue regions, avoiding violet shifts).
*
* 2. **Leverages Oklab's Simplicity and Speed (as a base for AOkLab):**
* AOkLab, built upon Oklab, is computationally more efficient than full
* Color Appearance Models (CAMs) like CIECAM16, making these advanced
* adjustments practical for dynamic applications.
*
* 3. **Integrates AOkLab's Surround Correction (New for an Oklab-based framework):**
* The core AOkLab model (`AdaptiveOklab` class from `aoklab.js`) adjusts its
* internal lightness mapping based on 'white', 'gray', or 'dark' surround
* conditions. This module uses a configured AOkLab instance, meaning all
* color transformations inherently account for the specified surround adaptation.
* This is achieved by AOkLab using a derived adaptive exponent `p` and a
* hue correction factor `x0^((1/3)-p)`.
*
* 4. **Enables Constant WCAG Contrast & CIE Luminance Compatibility (Novel Combination):**
* - Standard Oklab/AOkLab Lightness (L_ok / L_aok) does not directly correlate
* with CIE Luminance (Y) or provide straightforward WCAG contrast control.
* - This module solves this by allowing users to specify a **target CIELAB L*** value.
* This target L\* is converted to a target CIE Relative Luminance (Y_cie).
* - An iterative search then finds the **AOkLab Lightness (L_aok)** that, for a
* given AOkLab Hue, AOkLab Chroma, and AOkLab surround setting, produces an
* sRGB color matching the `Y_target_cie`.
* - This provides precise control over physical luminance for accessibility,
* while retaining the perceptual benefits (hue uniformity, surround adaptation)
* of the AOkLab color model for defining the color's chromatic characteristics.
*
* **In essence, designers gain two powerful, previously incompatible control mechanisms:**
* - Define color appearance (hue, colorfulness, adaptation to surround) using AOkLab.
* - Simultaneously enforce precise luminance levels (for WCAG contrast) by targeting
* a CIELAB L* value.
*/
// --- Type Imports for JSDoc ---
/**
* @typedef {object} AokChromaControlOptions
* @property {AdaptiveOklabOptions} [adaptiveOklabOptions]
* @property {number} [tolerance=1e-4]
* @property {number} [maxIterations=50]
* @property {number} [chromaStep=0.005]
* @property {number} [maxChromaSearchLimit=0.4]
*/
/** @typedef {AokChromaControlOptions & { globalTargetAokChroma?: number }} AdjustAokColorOptions */
/**
* @typedef {object} AokChromaControlResult
* @property {OklchColor} aokLCH
* @property {SrgbColor} srgbColor
* @property {number} relativeLuminanceY
* @property {boolean} outOfGamut
* @property {number} iterations
*/
// --- Utility and Color Module Imports ---
import { D65_WHITE_POINT_XYZ, normalizeHue } from './utils.js';
import { AdaptiveOklab } from './aoklab.js';
import { getSrgbRelativeLuminance } from './color-metrics.js';
// --- Constants for CIELAB L* to Y conversion ---
const EPSILON_CIELAB = Math.pow(6 / 29, 3);
const KAPPA_CIELAB = Math.pow(29 / 3, 3);
/**
* Converts a CIELAB L* value to CIE relative luminance Y (0-1 scale).
* @private
* @param {number} L_star - CIELAB Lightness L* [0, 100].
* @param {number} [referenceWhiteYn=100.0] - Y tristimulus of reference white.
* @returns {number} CIE Relative Luminance Y (0-1).
*/
function _labLToRelativeY(L_star, referenceWhiteYn = 100.0) {
if (typeof L_star !== 'number' || Number.isNaN(L_star) || L_star < 0 || L_star > 100) {
throw new TypeError('L_star must be a number between 0 and 100.');
}
if (typeof referenceWhiteYn !== 'number' || Number.isNaN(referenceWhiteYn) || referenceWhiteYn <= 0) {
throw new TypeError('referenceWhiteYn must be a positive number.');
}
const fy = (L_star + 16) / 116;
const yr = (fy > Math.cbrt(EPSILON_CIELAB)) ? Math.pow(fy, 3) : (116 * fy - 16) / KAPPA_CIELAB;
return Math.max(0, Math.min(1, yr * (referenceWhiteYn / 100.0)));
}
/**
* Core search: Finds AOkLab L for a target CIE Y.
* @private
* @param {number} targetCieY
* @param {number} targetAokChroma
* @param {number} targetAokHue
* @param {AdaptiveOklabOptions} aokOptions
* @param {Required<Pick<AokChromaControlOptions, 'tolerance' | 'maxIterations'>>} searchCtrlOptions
* @returns {{foundAokL: number, finalSrgb: SrgbColor, finalCieY: number, finalOutOfGamut: boolean, iterations: number}}
*/
function _findAokLForTargetCIELuminance(
targetCieY,
targetAokChroma,
targetAokHue,
aokOptions,
searchCtrlOptions
) {
const aokConverter = new AdaptiveOklab(aokOptions);
let lowAokL = 0.0;
let highAokL = 1.0;
let midAokL = Math.max(0, Math.min(1, targetCieY));
let iterations = 0;
let bestMatch = {
foundAokL: midAokL,
finalSrgb: { r: 0.5, g: 0.5, b: 0.5 },
finalCieY: 0.5,
finalOutOfGamut: true,
diffY: Infinity,
};
for (iterations = 0; iterations < searchCtrlOptions.maxIterations; iterations++) {
midAokL = (lowAokL + highAokL) / 2;
if (Math.abs(highAokL - lowAokL) < 1e-7) break;
// Convert LCH to Lab for AdaptiveOklab
const hueRad = (targetAokHue * Math.PI) / 180;
const candidateAokLab = {
L: midAokL,
a: targetAokChroma * Math.cos(hueRad),
b: targetAokChroma * Math.sin(hueRad)
};
const currentSrgb = aokConverter.toSrgb(candidateAokLab);
const currentCieY = getSrgbRelativeLuminance(currentSrgb);
const epsilonGamut = 1e-7;
const currentOutOfGamut =
currentSrgb.r < -epsilonGamut || currentSrgb.r > 1 + epsilonGamut ||
currentSrgb.g < -epsilonGamut || currentSrgb.g > 1 + epsilonGamut ||
currentSrgb.b < -epsilonGamut || currentSrgb.b > 1 + epsilonGamut;
const diffY = currentCieY - targetCieY;
if (!currentOutOfGamut) {
if (bestMatch.finalOutOfGamut || Math.abs(diffY) < Math.abs(bestMatch.diffY)) {
bestMatch = { foundAokL: midAokL, finalSrgb: currentSrgb, finalCieY: currentCieY, finalOutOfGamut: false, diffY };
}
if (Math.abs(diffY) < searchCtrlOptions.tolerance) break;
} else if (bestMatch.finalOutOfGamut && Math.abs(diffY) < Math.abs(bestMatch.diffY)) {
bestMatch = { foundAokL: midAokL, finalSrgb: currentSrgb, finalCieY: currentCieY, finalOutOfGamut: true, diffY };
}
if (diffY < 0) { lowAokL = midAokL; }
else { highAokL = midAokL; }
}
// Convert LCH to Lab for AdaptiveOklab
const finalHueRad = (targetAokHue * Math.PI) / 180;
const finalEvalAokLab = {
L: bestMatch.foundAokL,
a: targetAokChroma * Math.cos(finalHueRad),
b: targetAokChroma * Math.sin(finalHueRad)
};
bestMatch.finalSrgb = aokConverter.toSrgb(finalEvalAokLab);
bestMatch.finalCieY = getSrgbRelativeLuminance(bestMatch.finalSrgb);
const epsilonGamutFinal = 1e-7;
bestMatch.finalOutOfGamut =
bestMatch.finalSrgb.r < -epsilonGamutFinal || bestMatch.finalSrgb.r > 1 + epsilonGamutFinal ||
bestMatch.finalSrgb.g < -epsilonGamutFinal || bestMatch.finalSrgb.g > 1 + epsilonGamutFinal ||
bestMatch.finalSrgb.b < -epsilonGamutFinal || bestMatch.finalSrgb.b > 1 + epsilonGamutFinal;
return { ...bestMatch, iterations };
}
/**
* Default options for AOkLab chroma control functions.
* @type {Readonly<Required<AokChromaControlOptions>>}
*/
const DEFAULT_AOK_CHROMA_CONTROL_OPTIONS = Object.freeze({
adaptiveOklabOptions: { surround: 'gray', x0: 0.5 },
tolerance: 1e-4,
maxIterations: 50,
chromaStep: 0.005,
maxChromaSearchLimit: 0.4,
});
/**
* Finds the maximum AOkLab Chroma for a given AOkLab Hue that results in an sRGB color
* matching a target CIELAB L* (and thus a target CIE Y), within the sRGB gamut.
* AOkLab Lightness is internally adjusted by the search.
*
* @param {number} targetAokHueInput - Target AOkLab Hue in degrees [0, 360).
* @param {number} targetLabL_forY - Target CIELAB L* [0, 100], used to derive target CIE Y.
* @param {Partial<AokChromaControlOptions>} [userOptions={}] - Optional parameters.
* @returns {number} Maximum achievable AOkLab Chroma (C_aok), or 0 if no in-gamut solution.
* @throws {TypeError}
* @example
* const maxC = findMaxAokChromaForLabL(265, 50, {
* adaptiveOklabOptions: { surround: 'white' }
* });
* console.log(`Max C_aok for H_aok=265, L*_lab=50 (white surround): ${maxC}`);
*/
export function findMaxAokChromaForLabL(targetAokHueInput, targetLabL_forY, userOptions = {}) {
const targetAokHue = normalizeHue(targetAokHueInput);
if (typeof targetAokHue !== 'number' || Number.isNaN(targetAokHue)) {
throw new TypeError('targetAokHue must be a valid number.');
}
if (typeof targetLabL_forY !== 'number' || Number.isNaN(targetLabL_forY) || targetLabL_forY < 0 || targetLabL_forY > 100) {
throw new TypeError('targetLabL_forY must be a number between 0 and 100.');
}
const options = {
...DEFAULT_AOK_CHROMA_CONTROL_OPTIONS,
...userOptions,
adaptiveOklabOptions: {
...DEFAULT_AOK_CHROMA_CONTROL_OPTIONS.adaptiveOklabOptions,
...(userOptions.adaptiveOklabOptions || {})
}
};
const targetCieY = _labLToRelativeY(targetLabL_forY, D65_WHITE_POINT_XYZ.Y);
let currentAokChroma = options.maxChromaSearchLimit;
let maxInGamutAokChroma = 0;
while (currentAokChroma >= -options.chromaStep/2) {
const searchResult = _findAokLForTargetCIELuminance(
targetCieY,
Math.max(0, currentAokChroma),
targetAokHue,
options.adaptiveOklabOptions,
options
);
if (!searchResult.finalOutOfGamut && Math.abs(searchResult.finalCieY - targetCieY) < options.tolerance * 5) {
maxInGamutAokChroma = Math.max(0, currentAokChroma);
break;
}
currentAokChroma -= options.chromaStep;
}
// Final check for C=0 if nothing found, as achromatic should always be possible if Y is valid.
if (maxInGamutAokChroma === 0 && currentAokChroma < 0) {
const achromaticSearch = _findAokLForTargetCIELuminance(targetCieY, 0, targetAokHue, options.adaptiveOklabOptions, options);
if (!achromaticSearch.finalOutOfGamut && Math.abs(achromaticSearch.finalCieY - targetCieY) < options.tolerance * 5) {
return 0; // Achromatic is possible
}
// else truly nothing found, return 0, or could throw error if Y target is impossible (e.g. Y=0 or Y=1 with C>0)
}
return maxInGamutAokChroma;
}
/**
* Adjusts an AOkLab color (defined by Hue and target Chroma) to match a target CIELAB L*
* (by matching its corresponding CIE Luminance Y), respecting sRGB gamut.
* The output AOkLab Lightness is found by iterative search.
*
* @param {{L?: number, C: number, h: number}} inputAokLCHHint - Provides target AOkLab Hue (h),
* and target/initial AOkLab Chroma (C). The L component is ignored.
* @param {number} targetLabL_forY - Target CIELAB L* [0, 100] for the output's luminance.
* @param {'clip' | 'target'} mode - How chroma is handled:
* - 'clip': `inputAokLCHHint.C` is target, clipped to max achievable at `targetLabL_forY`.
* - 'target': `userOptions.globalTargetAokChroma` is target, clipped to max achievable.
* @param {Partial<AdjustAokColorOptions>} [userOptions={}] - Optional parameters.
* If `mode` is 'target', `userOptions.globalTargetAokChroma` is required.
* @returns {AokChromaControlResult} Result object.
* @throws {TypeError|Error}
* @example
* const blueHint = { C: 0.1, h: 265 };
* const result = adjustAokColorToLabL(blueHint, 50, 'clip', {
* adaptiveOklabOptions: { surround: 'white' }
* });
* if (!result.outOfGamut) {
* console.log('White adapted L*50 Blue:', result.aokLCH, 'sRGB:', result.srgbColor);
* }
*/
export function adjustAokColorToLabL(inputAokLCHHint, targetLabL_forY, mode, userOptions = {}) {
if (
typeof inputAokLCHHint !== 'object' || inputAokLCHHint === null ||
typeof inputAokLCHHint.C !== 'number' || Number.isNaN(inputAokLCHHint.C) ||
typeof inputAokLCHHint.h !== 'number' || Number.isNaN(inputAokLCHHint.h)
) {
throw new TypeError('inputAokLCHHint must be an object with C and h valid number properties.');
}
const targetAokHue = normalizeHue(inputAokLCHHint.h);
if (typeof targetLabL_forY !== 'number' || Number.isNaN(targetLabL_forY) || targetLabL_forY < 0 || targetLabL_forY > 100) {
throw new TypeError('targetLabL_forY must be a number between 0 and 100.');
}
if (mode !== 'clip' && mode !== 'target') {
throw new TypeError("Mode must be either 'clip' or 'target'.");
}
const options = {
...DEFAULT_AOK_CHROMA_CONTROL_OPTIONS,
...userOptions,
adaptiveOklabOptions: {
...DEFAULT_AOK_CHROMA_CONTROL_OPTIONS.adaptiveOklabOptions,
...(userOptions.adaptiveOklabOptions || {})
}
};
if (mode === 'target' && (typeof options.globalTargetAokChroma !== 'number' || Number.isNaN(options.globalTargetAokChroma))) {
throw new Error("The 'globalTargetAokChroma' option must be provided and be a valid number for 'target' mode.");
}
const maxAchievableAokChroma = findMaxAokChromaForLabL(targetAokHue, targetLabL_forY, options);
let finalTargetAokChroma;
if (mode === 'target') {
finalTargetAokChroma = Math.min(options.globalTargetAokChroma, maxAchievableAokChroma);
} else { // mode === 'clip'
finalTargetAokChroma = Math.min(inputAokLCHHint.C, maxAchievableAokChroma);
}
finalTargetAokChroma = Math.max(0, finalTargetAokChroma);
const targetCieY = _labLToRelativeY(targetLabL_forY, D65_WHITE_POINT_XYZ.Y);
const searchResult = _findAokLForTargetCIELuminance(
targetCieY,
finalTargetAokChroma,
targetAokHue,
options.adaptiveOklabOptions,
options
);
return {
aokLCH: { L: searchResult.foundAokL, C: finalTargetAokChroma, h: targetAokHue },
srgbColor: searchResult.finalSrgb,
relativeLuminanceY: searchResult.finalCieY,
outOfGamut: searchResult.finalOutOfGamut,
iterations: searchResult.iterations,
};
}