UNPKG

@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
/** * @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, }; }