colorizr
Version:
Manipulate colors like a boss
192 lines (165 loc) • 5.92 kB
text/typescript
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<T extends { l: number }>(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<T extends ColorModel>(
input: ConverterParameters<T>,
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<T extends LAB | LCH>(
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<number, number[]> = {
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];
}