import { COLOR_KEYS, MESSAGES } from '~/modules/constants'; import { invariant } from '~/modules/invariant'; import { isHSL, isLAB, isLCH, isNumber, isPlainObject, isRGB } from '~/modules/validators'; import { ColorModel, ColorModelKey, ConverterParameters, LAB, LCH } from '~/types'; /** * Clamp a value between a min and max. * * @param value - The value to clamp. * @param min - The minimum value (default: 0). * @param max - The maximum value (default: 100). * @returns The clamped value. */ export function clamp(value: number, min = 0, max = 100): number { return Math.min(Math.max(value, min), max); } /** * Constrain the degrees between 0 and 360. * * @param input - The base degrees value. * @param amount - The amount to add to the degrees. * @returns The constrained degrees value (0-360). */ export function constrainDegrees(input: number, amount: number): number { invariant(isNumber(input), MESSAGES.inputNumber); return (((input + amount) % 360) + 360) % 360; } /** * Normalize OkLab/OkLCH lightness from percentage (0-100) to 0-1 range. */ export function normalizeOkLightness(color: T): T { if (color.l > 1) { return { ...color, l: parseFloat((color.l / 100).toPrecision(15)) }; } return color; } /** * Parse the input parameters for converters. * * @param input - The converter parameters (object or tuple). * @param model - The target color model. * @returns The parsed color model object. */ export function parseInput( input: ConverterParameters, model: ColorModelKey, ): T { const keys = COLOR_KEYS[model]; const validator = { hsl: isHSL, oklab: isLAB, oklch: isLCH, rgb: isRGB, }; invariant(isPlainObject(input) || Array.isArray(input), MESSAGES.invalid); const value = Array.isArray(input) ? ({ [keys[0]]: input[0], [keys[1]]: input[1], [keys[2]]: input[2] } as unknown as T) : input; invariant(validator[model](value), `${MESSAGES.invalidColor}: ${model}`); return value; } /** * Restrict the values to a certain number of digits. * When precision is undefined, returns input unchanged (no rounding). * * @param input - The LAB or LCH color model. * @param precision - The number of significant digits. Undefined = no rounding. * @param forcePrecision - Whether to use decimal places (true) or significant digits (false). * @returns The color model with restricted values. */ export function restrictValues( input: T, precision?: number, forcePrecision = true, ): T { if (precision == null) { return input; } const output = new Map(Object.entries(input)); for (const [key, value] of output.entries()) { output.set(key, round(value, precision, forcePrecision)); } return Object.fromEntries(output) as T; } /** * Round decimal numbers. * * @param input - The number to round. * @param precision - The number of digits (default: 2). * @param forcePrecision - When true, rounds to N decimal places. When false, rounds to N significant digits. * @returns The rounded number. */ export function round(input: number, precision = 2, forcePrecision = true): number { if (!isNumber(input) || input === 0) { return 0; } if (forcePrecision) { const factor = 10 ** precision; return Math.round(input * factor) / factor; } // Significant digits mode (matches color.js toPrecision behavior): // For |n| >= 1: N significant digits. For |n| < 1: N decimal places. const integer = Math.trunc(input); let digits = 0; if (integer) { digits = Math.floor(Math.log10(Math.abs(integer))) + 1; } const factor = 10 ** (precision - digits); return Math.floor(input * factor + 0.5) / factor; } /** * Log a warning in development mode. * * @param message - The warning message to log. */ export function warn(message: string): void { if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line no-console console.warn(`[colorizr] ${message}`); } } /** * Pre-computed step keys for each step count (3-20). * * Based on Tailwind CSS color scale conventions: * - Standard keys: 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950 * - 500 is typically the "base" color * - Lower numbers = lighter, higher numbers = darker (in light mode) * * Keys are symmetrically distributed to maintain visual balance. */ const STEP_KEYS: Record = { 3: [100, 500, 900], 4: [100, 400, 600, 900], 5: [100, 300, 500, 700, 900], 6: [100, 200, 400, 600, 800, 900], 7: [100, 200, 400, 500, 600, 800, 900], 8: [100, 200, 300, 500, 600, 700, 800, 900], 9: [100, 200, 300, 400, 500, 600, 700, 800, 900], 10: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900], 11: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950], 12: [50, 100, 150, 200, 300, 400, 500, 600, 700, 800, 900, 950], 13: [50, 100, 150, 200, 300, 400, 500, 600, 700, 800, 850, 900, 950], 14: [50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 800, 850, 900, 950], 15: [50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 750, 800, 850, 900, 950], 16: [50, 100, 150, 200, 250, 300, 350, 400, 500, 600, 700, 750, 800, 850, 900, 950], 17: [50, 100, 150, 200, 250, 300, 350, 400, 500, 600, 650, 700, 750, 800, 850, 900, 950], 18: [50, 100, 150, 200, 250, 300, 350, 400, 450, 500, 600, 650, 700, 750, 800, 850, 900, 950], 19: [ 50, 100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 950, ], 20: [ 50, 100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 950, 1000, ], }; /** * Get the step keys for a given step count. * * @param steps - The number of steps (clamped to 3-20). * @returns The array of step keys. */ export function getScaleStepKeys(steps: number): number[] { const value = clamp(Math.round(steps), 3, 20); return STEP_KEYS[value]; }