@electrovir/color
Version:
A wrapper for culori with an extremely simple API.
324 lines (323 loc) • 12.8 kB
JavaScript
import { assert, assertWrap, check } from '@augment-vir/assert';
import { copyThroughJson, filterMap, getEnumValues, getObjectTypedEntries, getObjectTypedKeys, joinWithFinalConjunction, mapObjectValues, round, } from '@augment-vir/common';
import colorNames from 'color-name';
import { clampGamut, converter, differenceEuclidean, formatHex, parse, } from 'culori';
import { ColorFormatName, colorFormats, ColorSyntaxName, getColorSyntaxFromCssString, } from './color-formats.js';
import { maxColorNameLength } from './color-name-length.js';
const distanceRgb = differenceEuclidean('rgb');
/**
* A `Color` class with state and the following features:
*
* - Color coordinates do not change when they become `'none'`, they stay at their previous value.
* This makes for a much smoother UI experience.
* - All relevant color spaces and formats are always set (no extra conversions necessary).
* - Matching CSS color names are tracked.
* - This class is exported correctly and the types aren't a mess.
*
* @category Color
*/
export class Color {
constructor(
/** Any valid CSS color string or an object of color coordinate values. */
initValue) {
this.set(initValue);
}
/** Checks if the input string can be converted into a color. */
static isValidColorString(value) {
try {
new Color(value);
return true;
}
catch {
return false;
}
}
/** Checks, with a type guard, that a given input is a Color class instance. */
static isColor(value) {
return value instanceof Color;
}
/**
* Create a new {@link Color} instance by parsing the output of another instance's
* {@link Color.serialize} method.
*/
static deserialize(input) {
const parsed = JSON.parse(input);
const newColor = new Color('black');
getObjectTypedEntries(parsed).forEach(([key, value,]) => {
if (key === 'originalColorSyntax') {
newColor.originalColorSyntax = assertWrap.isEnumValue(value, ColorSyntaxName, 'Cannot deserialize: invalid color syntax.');
}
else {
newColor._allColors[key] = value;
}
});
return newColor;
}
/** Get the distance from this Color class instance to the given CSS color string. */
getRgbDistance(distanceFrom) {
return distanceRgb(this.#internalColor, distanceFrom);
}
/** Get the closest named CSS color to this Color class instance's current color. */
getClosestNamedColor() {
return getObjectTypedKeys(colorNames).reduce((best, currentName) => {
const colorDistance = this.getRgbDistance(currentName);
if (colorDistance < best.distance) {
return {
distance: colorDistance,
name: currentName,
};
}
else {
return best;
}
}, {
name: '',
distance: Infinity,
}).name;
}
/**
* Converts the color class to a CSS string format in the color space and format that it was
* originally set with.
*/
toString() {
return this.toCss()[this.originalColorSyntax];
}
/** The color syntax the this color was set with. */
originalColorSyntax = ColorSyntaxName.hex;
#internalColor = assertWrap.isDefined(parse('black'));
/** All current color values. These are updated whenever {@link Color.set} is called. */
_allColors = {
names: ['black'],
[ColorSyntaxName.name]: 'black',
hexString: '#000000',
[ColorSyntaxName.hex]: {
r: 0,
g: 0,
b: 0,
},
[ColorSyntaxName.rgb]: {
r: 0,
g: 0,
b: 0,
},
[ColorSyntaxName.hsl]: {
h: 0,
s: 0,
l: 0,
},
[ColorSyntaxName.hwb]: {
h: 0,
w: 0,
b: 0,
},
[ColorSyntaxName.lab]: {
l: 0,
a: 0,
b: 0,
},
[ColorSyntaxName.lch]: {
l: 0,
c: 0,
h: 0,
},
[ColorSyntaxName.oklab]: {
l: 0,
a: 0,
b: 0,
},
[ColorSyntaxName.oklch]: {
l: 0,
c: 0,
h: 0,
},
};
/** Create a new {@link Color} instance that matches this one exactly. */
clone() {
return Color.deserialize(this.serialize());
}
/**
* Update the color to match the given string.
*
* @throws Error if `cssColorString` is not able to be parsed.
*/
setByString(cssColorString) {
const newColor = parse(cssColorString);
if (!newColor) {
throw new Error(`Unable to parse invalid color string: '${cssColorString}'`);
}
this.originalColorSyntax = getColorSyntaxFromCssString(cssColorString);
this.#internalColor = newColor;
this.pullFromInternalColor();
}
/**
* Update the current color by setting a whole new color, a single coordinate in a single color
* format, or multiple coordinates in a single color format. This mutates the current
* {@link Color} instance.
*/
set(newValue) {
if (check.isString(newValue)) {
return this.setByString(newValue);
}
assert.isLengthExactly(Object.keys(newValue), 1, `Cannot set multiple color formats at once: got '${joinWithFinalConjunction(Object.keys(newValue))}'`);
if (newValue.hexString || newValue.name) {
this.setByString(newValue.hexString || newValue.name);
}
else {
const [colorFormatName, colorValues,] = assertWrap.isDefined(getObjectTypedEntries(newValue)[0]);
const colorFormatDefinition = colorFormats[colorFormatName];
const orderedColorCoords = Object.values(mapObjectValues(colorFormatDefinition.coords, (coordName) => {
const coordValue = colorValues[coordName];
const coordDefinition = colorFormatDefinition.coords[assertWrap.isKeyOf(coordName, colorFormatDefinition.coords)];
const rawCoordValue = coordValue != undefined &&
coordValue >= coordDefinition.min &&
coordValue <= coordDefinition.max
? colorValues[coordName]
: this[colorFormatName][coordName];
return assertWrap.isDefined(rawCoordValue);
}));
this.setByString(`${colorFormatDefinition.conversionFormat}(${orderedColorCoords.join(' ')})`);
}
}
/**
* Update all internally stored color format values ({@link Color.allColors}) from the updated
* internal color object.
*/
pullFromInternalColor() {
getEnumValues(ColorFormatName).forEach((colorFormatName) => {
const colorFormatDefinition = colorFormats[colorFormatName];
const mappedColorFormatName = colorFormatDefinition.conversionFormat;
const originalColorDefinition = check.isKeyOf(this.#internalColor.mode, colorFormats)
? colorFormats[this.#internalColor.mode]
: undefined;
const converted = clampGamut(colorFormatDefinition.colorSpace === originalColorDefinition?.colorSpace
? mappedColorFormatName
: 'rgb')(converter(mappedColorFormatName)(this.#internalColor));
/* node:coverage ignore next 5: technically this shouldn't happen, idk how to manually trigger it. */
if (!converted) {
assert.never(`Failed to convert color '${JSON.stringify(this.#internalColor)}' to '${colorFormatName}'.`);
}
getObjectTypedKeys(this[colorFormatName]).forEach((coordName) => {
const coordValue = converted[coordName];
const coordinateDefinition = colorFormatDefinition.coords[assertWrap.isKeyOf(coordName, colorFormatDefinition.coords)];
if (coordValue != undefined) {
this._allColors[colorFormatName][coordName] = round((coordValue || 0) * (coordinateDefinition.factor || 1), {
digits: coordinateDefinition.digits || 0,
});
}
});
});
this._allColors.hexString = formatHex(this.#internalColor);
this._allColors.names = findMatchingColorNames(this.rgb);
this._allColors[ColorSyntaxName.name] = this._allColors.names[0] || '';
}
/**
* Create a string that can be serialized into a new {@link Color} instance which will exactly
* match the current {@link Color} instance.
*/
serialize() {
return JSON.stringify({
...this.allColors,
originalColorSyntax: this.originalColorSyntax,
});
}
/** This individual color expressed in all the supported color formats. */
get allColors() {
return copyThroughJson(this._allColors);
}
/**
* Converts the values for each supported color format into a padded string for easy display
* purposes.
*
* @see `.toCss()`
*/
toFormattedStrings() {
const colorFormatStrings = mapObjectValues(colorFormats, (colorFormatName) => {
const coordValues = Object.values(this[colorFormatName]);
return coordValues.map((coordValue) => String(coordValue).padStart(6, ' ')).join(' ');
});
return {
...colorFormatStrings,
names: this.names.join(', ').padEnd(maxColorNameLength, ' '),
[ColorSyntaxName.name]: (this.names[0] || '').padEnd(maxColorNameLength, ' '),
[ColorSyntaxName.hexString]: this[ColorSyntaxName.hexString],
};
}
/**
* Converts the values for each supported color format in a CSS string that can be directly used
* in any modern CSS code.
*
* @see `.toFormattedStrings()`
*/
toCss() {
const colorFormatStrings = mapObjectValues(colorFormats, (colorFormatName) => {
const coordValues = Object.values(this[colorFormatName]);
return `${colorFormatName}(${coordValues.join(' ')})`;
});
return {
...colorFormatStrings,
[ColorSyntaxName.hexString]: this[ColorSyntaxName.hexString],
[ColorSyntaxName.name]: this.names[0] || '',
};
}
/**
* The current color expressed as hardcoded CSS color keywords. If no CSS color keywords match
* the current color, this array will be empty.
*/
get names() {
return copyThroughJson(this._allColors.names);
}
/**
* The current color expressed as a single CSS color name string. If there is no color name that
* matches the current color, this will be an empty string.
*/
get name() {
return this._allColors.names[0] || '';
}
/** The current color expressed as an RGB hex string. */
get hexString() {
return this._allColors[ColorSyntaxName.hexString];
}
/** The current color expressed as an RGB hex coordinates. */
get hex() {
return copyThroughJson(this._allColors[ColorSyntaxName.hex]);
}
/** The current color expressed as its RGB coordinate values. */
get rgb() {
return copyThroughJson(this._allColors[ColorSyntaxName.rgb]);
}
/** The current color expressed as its HSL coordinate values. */
get hsl() {
return copyThroughJson(this._allColors[ColorSyntaxName.hsl]);
}
/** The current color expressed as its HWB coordinate values. */
get hwb() {
return copyThroughJson(this._allColors[ColorSyntaxName.hwb]);
}
/** The current color expressed as its LAB coordinate values. */
get lab() {
return copyThroughJson(this._allColors[ColorSyntaxName.lab]);
}
/** The current color expressed as its LCH coordinate values. */
get lch() {
return copyThroughJson(this._allColors[ColorSyntaxName.lch]);
}
/** The current color expressed as its Oklab coordinate values. */
get oklab() {
return copyThroughJson(this._allColors[ColorSyntaxName.oklab]);
}
/** The current color expressed as its Oklch coordinate values. */
get oklch() {
return copyThroughJson(this._allColors[ColorSyntaxName.oklch]);
}
}
function findMatchingColorNames(rgb) {
return filterMap(getObjectTypedEntries(colorNames), ([colorName,]) => {
return colorName;
}, (colorName, [, colorValues,]) => {
return check.deepEquals(colorValues, [
rgb.r,
rgb.g,
rgb.b,
]);
});
}