@markgorzynski/color-utils
Version:
The only color science library with adaptive visual perception modeling and accessibility-driven optimization.
252 lines (228 loc) • 7.62 kB
JavaScript
/**
* @module gamut
* @description Gamut validation and management functions following industry standards.
*
* DESIGN PRINCIPLES:
* ==================
* - Core conversions process out-of-range values
* - Validation is opt-in via these utility functions
* - Multiple strategies for different use cases
* - No automatic clamping in mathematical operations
*
* @see RANGE_STANDARDS.md for detailed conventions
*/
/**
* Checks if an sRGB color is within the standard [0,1] gamut.
*
* @param {SrgbColor} srgb - sRGB color to check
* @param {number} [epsilon=1e-10] - Tolerance for floating point comparison
* @returns {boolean} True if all components are within [0,1]
*
* @example
* isSrgbInGamut({r: 0.5, g: 0.3, b: 0.8}) // true
* isSrgbInGamut({r: 1.1, g: 0.5, b: -0.1}) // false
* isSrgbInGamut({r: 1.0000000001, g: 0.5, b: 0.5}, 1e-9) // true (within tolerance)
*/
export function isSrgbInGamut(srgb, epsilon = 1e-10) {
return srgb.r >= -epsilon && srgb.r <= 1 + epsilon &&
srgb.g >= -epsilon && srgb.g <= 1 + epsilon &&
srgb.b >= -epsilon && srgb.b <= 1 + epsilon;
}
/**
* Simple clamping of sRGB values to [0,1] range.
* Fast but can shift hue for out-of-gamut colors.
*
* @param {SrgbColor} srgb - sRGB color to clamp
* @returns {SrgbColor} Clamped color with all components in [0,1]
*
* @example
* clampSrgb({r: 1.2, g: 0.5, b: -0.1}) // {r: 1, g: 0.5, b: 0}
*/
export function clampSrgb(srgb) {
return {
r: Math.max(0, Math.min(1, srgb.r)),
g: Math.max(0, Math.min(1, srgb.g)),
b: Math.max(0, Math.min(1, srgb.b))
};
}
/**
* Checks if a CIELAB color is within typical display gamut.
* Note: Lab space can represent colors outside any display gamut.
*
* @param {LabColor} lab - CIELAB color to check
* @returns {boolean} True if within typical ranges
*
* Typical ranges:
* - L*: [0, 100]
* - a*: [-128, 127]
* - b*: [-128, 127]
*/
export function isLabInTypicalRange(lab) {
return lab.L >= 0 && lab.L <= 100 &&
lab.a >= -128 && lab.a <= 127 &&
lab.b >= -128 && lab.b <= 127;
}
/**
* Checks if an Oklab color is within typical display gamut.
*
* @param {OklabColor} oklab - Oklab color to check
* @returns {boolean} True if within typical ranges
*
* Typical ranges:
* - L: [0, 1]
* - a: [-0.4, 0.4]
* - b: [-0.4, 0.4]
*/
export function isOklabInTypicalRange(oklab) {
return oklab.L >= 0 && oklab.L <= 1 &&
oklab.a >= -0.4 && oklab.a <= 0.4 &&
oklab.b >= -0.4 && oklab.b <= 0.4;
}
/**
* Validates that an object has the required sRGB properties.
* Does NOT validate ranges, only structure.
*
* @param {any} obj - Object to validate
* @returns {boolean} True if object has numeric r, g, b properties
*/
export function isValidSrgbObject(obj) {
return obj != null &&
typeof obj === 'object' &&
typeof obj.r === 'number' && !Number.isNaN(obj.r) &&
typeof obj.g === 'number' && !Number.isNaN(obj.g) &&
typeof obj.b === 'number' && !Number.isNaN(obj.b);
}
/**
* Validates that an object has the required Lab properties.
* Does NOT validate ranges, only structure.
*
* @param {any} obj - Object to validate
* @returns {boolean} True if object has numeric L, a, b properties
*/
export function isValidLabObject(obj) {
return obj != null &&
typeof obj === 'object' &&
typeof obj.L === 'number' && !Number.isNaN(obj.L) &&
typeof obj.a === 'number' && !Number.isNaN(obj.a) &&
typeof obj.b === 'number' && !Number.isNaN(obj.b);
}
/**
* Clamps a numeric value to a range.
* Helper function for custom clamping operations.
*
* @param {number} value - Value to clamp
* @param {number} min - Minimum value
* @param {number} max - Maximum value
* @returns {number} Clamped value
*/
export function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
/**
* Scales an sRGB color uniformly until it fits within gamut.
* Preserves hue better than channel clamping but reduces saturation.
*
* @param {SrgbColor} srgb - sRGB color to scale
* @returns {SrgbColor} Scaled color within [0,1] gamut
*
* @example
* scaleToSrgbGamut({r: 1.2, g: 0.6, b: 0.3}) // Scales all channels by 1/1.2
*/
export function scaleToSrgbGamut(srgb) {
const maxChannel = Math.max(srgb.r, srgb.g, srgb.b);
const minChannel = Math.min(srgb.r, srgb.g, srgb.b);
// If already in gamut, return as-is
if (maxChannel <= 1 && minChannel >= 0) {
return srgb;
}
// For colors with negative values, we need to shift and scale
if (minChannel < 0) {
// Shift all channels to make minimum 0
const shift = -minChannel;
const shiftedR = srgb.r + shift;
const shiftedG = srgb.g + shift;
const shiftedB = srgb.b + shift;
const maxShifted = Math.max(shiftedR, shiftedG, shiftedB);
// Scale down if needed
if (maxShifted > 1) {
const scale = 1 / maxShifted;
return {
r: shiftedR * scale,
g: shiftedG * scale,
b: shiftedB * scale
};
}
return { r: shiftedR, g: shiftedG, b: shiftedB };
}
// For colors that only exceed 1, simple scaling
if (maxChannel > 1) {
const scale = 1 / maxChannel;
return {
r: srgb.r * scale,
g: srgb.g * scale,
b: srgb.b * scale
};
}
return srgb;
}
/**
* Gets the maximum displayable chroma for a given lightness and hue.
* Uses binary search to find the gamut boundary.
*
* @param {number} L - Lightness (0-100 for Lab, 0-1 for Oklab)
* @param {number} h - Hue angle in degrees [0, 360)
* @param {string} [space='oklch'] - Color space to use ('lch' or 'oklch')
* @param {number} [precision=0.001] - Search precision
* @returns {number} Maximum chroma that stays in sRGB gamut
*/
export function getMaxChroma(L, h, space = 'oklch', precision = 0.001) {
// Import only if needed to avoid circular dependencies
const { lchToSrgb } = require('./cielab.js');
const { oklchToSrgb } = require('./oklab.js');
const toSrgb = space === 'lch' ? lchToSrgb : oklchToSrgb;
// Binary search for maximum chroma
let low = 0;
let high = space === 'lch' ? 150 : 0.5; // Reasonable upper bounds
while (high - low > precision) {
const mid = (low + high) / 2;
const color = { L, C: mid, h };
const srgb = toSrgb(color);
if (isSrgbInGamut(srgb)) {
low = mid;
} else {
high = mid;
}
}
return low;
}
/**
* Information about gamut check results.
* @typedef {Object} GamutInfo
* @property {boolean} inGamut - Whether color is in gamut
* @property {number[]} channels - Array of channel values
* @property {number} maxExcess - Maximum amount any channel exceeds bounds
* @property {number} minDeficit - Maximum amount any channel is below bounds
*/
/**
* Detailed gamut check with information about how far out of gamut.
*
* @param {SrgbColor} srgb - sRGB color to check
* @returns {GamutInfo} Detailed gamut information
*
* @example
* const info = getSrgbGamutInfo({r: 1.2, g: 0.5, b: -0.1});
* // { inGamut: false, channels: [1.2, 0.5, -0.1], maxExcess: 0.2, minDeficit: 0.1 }
*/
export function getSrgbGamutInfo(srgb) {
const channels = [srgb.r, srgb.g, srgb.b];
const maxExcess = Math.max(0, ...channels.map(c => c - 1));
const minDeficit = Math.max(0, ...channels.map(c => -c));
return {
inGamut: maxExcess === 0 && minDeficit === 0,
channels,
maxExcess,
minDeficit
};
}
// Re-export the advanced gamut mapping from existing module
export { gamutMapOklch, gamutMapSrgb, isInGamut } from './gamut-mapping.js';