Source: oklab.js

/**
 * @file src/oklab.js
 * @module color-utils/oklab
 * @description Functions for Oklab and OkLCh color space conversions.
 * Includes transformations to/from Linear sRGB and sRGB (gamma-corrected),
 * and between Oklab and OkLCh.
 * Oklab components (L, a, b) and OkLCh components (L, C, h) follow standard definitions
 * by Björn Ottosson, with Oklab L (Lightness) typically in the [0, 1] range.
 * @see {@link https://bottosson.github.io/posts/oklab/} for the Oklab specification.
 */

// --- Type Imports for JSDoc ---

// --- Utility Imports ---
import {
  multiplyMatrixVector,
  degreesToRadians,
  radiansToDegrees,
  normalizeHue, // Assuming this is exported from utils.js
  signPreservingPow, // For cube root and cubing, handles signs correctly
} from './utils.js';

// --- sRGB Module Imports (for convenience functions) ---
import {
  srgbToLinearSrgb,
  linearSrgbToSrgb,
} from './srgb.js';

// --- Constants for Oklab Transformations ---
// These matrices and steps are based on Björn Ottosson's Oklab formulation.

// Step 1: Convert Linear sRGB to an intermediate LMS-like space (specific to Oklab)
const MATRIX_LINEAR_SRGB_TO_LMS_OKLAB = Object.freeze([
  Object.freeze([0.4122214708, 0.5363325363, 0.0514459929]),
  Object.freeze([0.2119034982, 0.6806995451, 0.1073969566]),
  Object.freeze([0.0883024619, 0.2817188376, 0.6299787005]),
]);

// Step 2: Non-linearity applied to LMS (typically cube root)
// This is handled by signPreservingPow(value, 1/3)

// Step 3: Convert non-linear LMS' to Oklab (L, a, b)
const MATRIX_LMS_PRIME_TO_OKLAB = Object.freeze([
  Object.freeze([0.2104542553, 0.7936177850, -0.0040720468]),
  Object.freeze([1.9779984951, -2.4285922050, 0.4505937099]),
  Object.freeze([0.0259040371, 0.7827717662, -0.8086757660]),
]);

// Inverse Transformations:

// Step 1 (Inverse of Step 3): Convert Oklab (L, a, b) to non-linear LMS'
const MATRIX_OKLAB_TO_LMS_PRIME = Object.freeze([
  Object.freeze([1.0, 0.3963377774, 0.2158037573]),
  Object.freeze([1.0, -0.1055613458, -0.0638541728]),
  Object.freeze([1.0, -0.0894841775, -1.2914855480]),
]);

// Step 2 (Inverse of Step 2): Apply inverse non-linearity (cubing)
// This is handled by signPreservingPow(value, 3)

// Step 3 (Inverse of Step 1): Convert LMS to Linear sRGB
const MATRIX_LMS_OKLAB_TO_LINEAR_SRGB = Object.freeze([
  Object.freeze([4.0767416621, -3.3077115913, 0.2309699292]),
  Object.freeze([-1.2684380046, 2.6097574011, -0.3413193965]),
  Object.freeze([-0.0041960863, -0.7034186147, 1.7076147010]),
]);

// --- Linear sRGB <-> Oklab Conversions ---

/**
 * Converts a Linear sRGB color object to an Oklab color object.
 * @param {LinearSrgbColor} linearSrgbColor - The Linear sRGB color object {r, g, b} with components in [0, 1].
 * @returns {OklabColor} The Oklab color object {L, a, b}.
 * @throws {TypeError} if `linearSrgbColor` is not a valid LinearSrgbColor object.
 * @example
 * const oklabWhite = linearSrgbToOklab({ r: 1, g: 1, b: 1 });
 * // oklabWhite ≈ { L: 1, a: 0, b: 0 }
 * const oklabRed = linearSrgbToOklab({ r: 1, g: 0, b: 0 });
 * // oklabRed ≈ { L: 0.628, a: 0.225, b: 0.126 }
 */
export function linearSrgbToOklab(linearSrgbColor) {
  if (
    typeof linearSrgbColor !== 'object' || linearSrgbColor === null ||
    typeof linearSrgbColor.r !== 'number' || Number.isNaN(linearSrgbColor.r) ||
    typeof linearSrgbColor.g !== 'number' || Number.isNaN(linearSrgbColor.g) ||
    typeof linearSrgbColor.b !== 'number' || Number.isNaN(linearSrgbColor.b)
  ) {
    throw new TypeError('Input linearSrgbColor must be an object with r, g, b valid number properties.');
  }
  // Ensure components are non-negative for physical light before matrix multiply.
  const r = Math.max(0, linearSrgbColor.r);
  const g = Math.max(0, linearSrgbColor.g);
  const b = Math.max(0, linearSrgbColor.b);

  const lms = multiplyMatrixVector(MATRIX_LINEAR_SRGB_TO_LMS_OKLAB, [r, g, b]);

  // Apply cube-root non-linearity using sign-preserving power for negative intermediate results.
  // Oklab uses `cbrtf` which correctly handles negative numbers by preserving the sign.
  const lmsPrime = [
    signPreservingPow(lms[0], 1 / 3),
    signPreservingPow(lms[1], 1 / 3),
    signPreservingPow(lms[2], 1 / 3),
  ];

  const labArray = multiplyMatrixVector(MATRIX_LMS_PRIME_TO_OKLAB, lmsPrime);
  return { L: labArray[0], a: labArray[1], b: labArray[2] };
}

/**
 * Converts an Oklab color object to a Linear sRGB color object.
 * @param {OklabColor} oklabColor - The Oklab color object {L, a, b}.
 * @returns {LinearSrgbColor} The Linear sRGB color object {r, g, b}.
 * Components may be outside [0, 1] if the Oklab color is out of the sRGB gamut.
 * @throws {TypeError} if `oklabColor` is not a valid OklabColor object.
 * @example
 * const linearSRGBWhite = oklabToLinearSrgb({ L: 1, a: 0, b: 0 });
 * // linearSRGBWhite ≈ { r: 1, g: 1, b: 1 }
 * const linearSRGBRed = oklabToLinearSrgb({ L: 0.627955, a: 0.224863, b: 0.125846 });
 * // linearSRGBRed ≈ { r: 1, g: 0, b: 0 }
 */
export function oklabToLinearSrgb(oklabColor) {
  if (
    typeof oklabColor !== 'object' || oklabColor === null ||
    typeof oklabColor.L !== 'number' || Number.isNaN(oklabColor.L) ||
    typeof oklabColor.a !== 'number' || Number.isNaN(oklabColor.a) ||
    typeof oklabColor.b !== 'number' || Number.isNaN(oklabColor.b)
  ) {
    throw new TypeError('Input oklabColor must be an object with L, a, b valid number properties.');
  }
  const { L, a, b } = oklabColor;

  const lmsPrime = multiplyMatrixVector(MATRIX_OKLAB_TO_LMS_PRIME, [L, a, b]);

  // Apply cubing (inverse of cube-root non-linearity) using sign-preserving power.
  const lms = [
    signPreservingPow(lmsPrime[0], 3),
    signPreservingPow(lmsPrime[1], 3),
    signPreservingPow(lmsPrime[2], 3),
  ];

  const rgbArray = multiplyMatrixVector(MATRIX_LMS_OKLAB_TO_LINEAR_SRGB, lms);
  return { r: rgbArray[0], g: rgbArray[1], b: rgbArray[2] };
}

// --- Oklab <-> OkLCh Conversions ---

/**
 * Converts an Oklab color object to an OkLCh color object.
 * Hue (h) is returned in degrees [0, 360).
 * @param {OklabColor} oklabColor - The Oklab color object {L, a, b}.
 * @returns {OklchColor} The OkLCh color object {L, C, h}.
 * @throws {TypeError} if `oklabColor` is not a valid OklabColor object.
 * @example
 * const oklch = oklabToOklch({ L: 0.6, a: 0.1, b: 0.1 });
 * // L=0.6, C ≈ 0.1414, h=45
 */
export function oklabToOklch(oklabColor) {
  if (
    typeof oklabColor !== 'object' || oklabColor === null ||
    typeof oklabColor.L !== 'number' || Number.isNaN(oklabColor.L) ||
    typeof oklabColor.a !== 'number' || Number.isNaN(oklabColor.a) ||
    typeof oklabColor.b !== 'number' || Number.isNaN(oklabColor.b)
  ) {
    throw new TypeError('Input oklabColor must be an object with L, a, b valid number properties.');
  }
  const { L, a, b } = oklabColor;

  const C = Math.sqrt(a * a + b * b);
  const h_rad = Math.atan2(b, a);
  let h_deg = radiansToDegrees(h_rad);

  h_deg = normalizeHue(h_deg); // Normalize to [0, 360)

  if (C < 1e-7) { // Tolerance for C being effectively zero
      h_deg = 0; // Hue is undefined for achromatic colors, conventionally set to 0.
  }

  return { L, C, h: h_deg };
}

/**
 * Converts an OkLCh color object to an Oklab color object.
 * Hue (h) is expected in degrees.
 * @param {OklchColor} oklchColor - The OkLCh color object {L, C, h}.
 * @returns {OklabColor} The Oklab color object {L, a, b}.
 * @throws {TypeError} if `oklchColor` is not a valid OklchColor object.
 * @example
 * const oklab = oklchToOklab({ L: 0.6, C: 0.141421356, h: 45 });
 * // L=0.6, a ≈ 0.1, b ≈ 0.1
 */
export function oklchToOklab(oklchColor) {
  if (
    typeof oklchColor !== 'object' || oklchColor === null ||
    typeof oklchColor.L !== 'number' || Number.isNaN(oklchColor.L) ||
    typeof oklchColor.C !== 'number' || Number.isNaN(oklchColor.C) ||
    typeof oklchColor.h !== 'number' || Number.isNaN(oklchColor.h)
  ) {
    throw new TypeError('Input oklchColor must be an object with L, C, h valid number properties.');
  }
  const { L, C, h } = oklchColor;
  const C_abs = Math.max(0, C); // Chroma cannot be negative

  const h_rad = degreesToRadians(h);
  const a = C_abs * Math.cos(h_rad);
  const b = C_abs * Math.sin(h_rad);

  if (C_abs < 1e-7) {
    return { L, a: 0, b: 0 };
  }

  return { L, a, b };
}

// --- sRGB <-> Oklab Convenience Pipeline Functions ---

/**
 * Converts an sRGB color object directly to an Oklab color object.
 * @param {SrgbColor} srgbColor - The sRGB color object {r, g, b} with components in [0, 1].
 * @returns {OklabColor} The Oklab color object {L, a, b}.
 * @throws {TypeError} if `srgbColor` is not a valid SrgbColor object.
 * @example
 * const oklabRed = srgbToOklab({ r: 1, g: 0, b: 0 });
 * // oklabRed ≈ { L: 0.628, a: 0.225, b: 0.126 }
 */
export function srgbToOklab(srgbColor) {
  // Type checking for srgbColor is handled by srgbToLinearSrgb
  const linearSrgb = srgbToLinearSrgb(srgbColor); // from srgb.js
  return linearSrgbToOklab(linearSrgb);
}

/**
 * Converts an Oklab color object directly to an sRGB (gamma-corrected) color object.
 * The resulting sRGB values are not guaranteed to be in gamut [0,1].
 * `linearSrgbToSrgb` (from srgb.js) will apply gamma correction; resulting values might
 * still be outside [0,1] if the Oklab color was far out of sRGB gamut.
 * @param {OklabColor} oklabColor - The Oklab color object {L, a, b}.
 * @returns {SrgbColor} The sRGB color object {r, g, b}.
 * @throws {TypeError} if `oklabColor` is not a valid OklabColor object.
 * @example
 * const srgbRed = oklabToSrgb({ L: 0.627955, a: 0.224863, b: 0.125846 });
 * // srgbRed ≈ { r: 1, g: 0, b: 0 }
 */
export function oklabToSrgb(oklabColor) {
  // Type checking for oklabColor is handled by oklabToLinearSrgb
  const linearSrgb = oklabToLinearSrgb(oklabColor);
  return linearSrgbToSrgb(linearSrgb); // from srgb.js
}

// --- sRGB <-> OkLCh Convenience Pipeline Functions ---

/**
 * Converts an sRGB color object directly to an OkLCh color object.
 * @param {SrgbColor} srgbColor - The sRGB color object {r, g, b} with components in [0, 1].
 * @returns {OklchColor} The OkLCh color object {L, C, h}.
 * @throws {TypeError} if `srgbColor` is not a valid SrgbColor object.
 * @example
 * const oklchRed = srgbToOklch({ r: 1, g: 0, b: 0 });
 * // oklchRed ≈ { L: 0.628, C: 0.258, h: 29.2 }
 */
export function srgbToOklch(srgbColor) {
    const oklab = srgbToOklab(srgbColor); // Type checking handled by srgbToOklab
    return oklabToOklch(oklab);
}

/**
 * Converts an OkLCh color object directly to an sRGB (gamma-corrected) color object.
 * The resulting sRGB values are not guaranteed to be in gamut [0,1].
 * @param {OklchColor} oklchColor - The OkLCh color object {L, C, h}.
 * @returns {SrgbColor} The sRGB color object {r, g, b}.
 * @throws {TypeError} if `oklchColor` is not a valid OklchColor object.
 * @example
 * const srgbRed = oklchToSrgb({ L: 0.627955, C: 0.25782, h: 29.233 });
 * // srgbRed ≈ { r: 1, g: 0, b: 0 }
 */
export function oklchToSrgb(oklchColor) {
    const oklab = oklchToOklab(oklchColor); // Type checking handled by oklchToOklab
    return oklabToSrgb(oklab);
}