@react-stately/color
Version:
Spectrum UI components in React
793 lines (699 loc) • 25.6 kB
text/typescript
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {clamp, toFixedNumber} from '@react-stately/utils';
import {ColorAxes, ColorChannel, ColorChannelRange, ColorFormat, ColorSpace, Color as IColor} from '@react-types/color';
// @ts-ignore
import intlMessages from '../intl/*.json';
import {LocalizedStringDictionary, LocalizedStringFormatter} from '@internationalized/string';
import {NumberFormatter} from '@internationalized/number';
let dictionary = new LocalizedStringDictionary(intlMessages);
/** Parses a color from a string value. Throws an error if the string could not be parsed. */
export function parseColor(value: string): IColor {
let res = RGBColor.parse(value) || HSBColor.parse(value) || HSLColor.parse(value);
if (res) {
return res;
}
throw new Error('Invalid color value: ' + value);
}
export function normalizeColor(v: string | IColor) {
if (typeof v === 'string') {
return parseColor(v);
} else {
return v;
}
}
/** Returns a list of color channels for a given color space. */
export function getColorChannels(colorSpace: ColorSpace) {
switch (colorSpace) {
case 'rgb':
return RGBColor.colorChannels;
case 'hsl':
return HSLColor.colorChannels;
case 'hsb':
return HSBColor.colorChannels;
}
}
/**
* Returns the hue value normalized to the range of 0 to 360.
*/
export function normalizeHue(hue: number) {
if (hue === 360) {
return hue;
}
return ((hue % 360) + 360) % 360;
}
// Lightness threshold between orange and brown.
const ORANGE_LIGHTNESS_THRESHOLD = 0.68;
// Lightness threshold between pure yellow and "yellow green".
const YELLOW_GREEN_LIGHTNESS_THRESHOLD = 0.85;
// The maximum lightness considered to be "dark".
const MAX_DARK_LIGHTNESS = 0.55;
// The chroma threshold between gray and color.
const GRAY_THRESHOLD = 0.001;
const OKLCH_HUES: [number, string][] = [
[0, 'pink'],
[15, 'red'],
[48, 'orange'],
[94, 'yellow'],
[135, 'green'],
[175, 'cyan'],
[264, 'blue'],
[284, 'purple'],
[320, 'magenta'],
[349, 'pink']
];
abstract class Color implements IColor {
abstract toFormat(format: ColorFormat): IColor;
abstract toString(format: ColorFormat | 'css'): string;
abstract clone(): IColor;
abstract getChannelRange(channel: ColorChannel): ColorChannelRange;
abstract getChannelFormatOptions(channel: ColorChannel): Intl.NumberFormatOptions;
abstract formatChannelValue(channel: ColorChannel, locale: string): string;
toHexInt(): number {
return this.toFormat('rgb').toHexInt();
}
getChannelValue(channel: ColorChannel): number {
if (channel in this) {
return this[channel];
}
throw new Error('Unsupported color channel: ' + channel);
}
withChannelValue(channel: ColorChannel, value: number): IColor {
if (channel in this) {
let x = this.clone();
x[channel] = value;
return x;
}
throw new Error('Unsupported color channel: ' + channel);
}
getChannelName(channel: ColorChannel, locale: string) {
let strings = LocalizedStringDictionary.getGlobalDictionaryForPackage('@react-stately/color') || dictionary;
return strings.getStringForLocale(channel, locale);
}
abstract getColorSpace(): ColorSpace;
getColorSpaceAxes(xyChannels: {xChannel?: ColorChannel, yChannel?: ColorChannel}): ColorAxes {
let {xChannel, yChannel} = xyChannels;
let xCh = xChannel || this.getColorChannels().find(c => c !== yChannel)!;
let yCh = yChannel || this.getColorChannels().find(c => c !== xCh)!;
let zCh = this.getColorChannels().find(c => c !== xCh && c !== yCh)!;
return {xChannel: xCh, yChannel: yCh, zChannel: zCh};
}
abstract getColorChannels(): [ColorChannel, ColorChannel, ColorChannel]
getColorName(locale: string): string {
// Convert to oklch color space, which has perceptually uniform lightness across all hues.
let [l, c, h] = toOKLCH(this);
let strings = LocalizedStringDictionary.getGlobalDictionaryForPackage('@react-stately/color') || dictionary;
if (l > 0.999) {
return strings.getStringForLocale('white', locale);
}
if (l < 0.001) {
return strings.getStringForLocale('black', locale);
}
let hue: string;
[hue, l] = this.getOklchHue(l, c, h, locale);
let lightness = '';
let chroma = '';
if (c <= 0.1 && c >= GRAY_THRESHOLD) {
if (l >= 0.7) {
chroma = 'pale';
} else {
chroma = 'grayish';
}
} else if (c >= 0.15) {
chroma = 'vibrant';
}
if (l < 0.3) {
lightness = 'very dark';
} else if (l < MAX_DARK_LIGHTNESS) {
lightness = 'dark';
} else if (l < 0.7) {
// none
} else if (l < 0.85) {
lightness = 'light';
} else {
lightness = 'very light';
}
if (chroma) {
chroma = strings.getStringForLocale(chroma, locale);
}
if (lightness) {
lightness = strings.getStringForLocale(lightness, locale);
}
let alpha = this.getChannelValue('alpha');
let formatter = new LocalizedStringFormatter(locale, strings);
if (alpha < 1) {
let percentTransparent = new NumberFormatter(locale, {style: 'percent'}).format(1 - alpha);
return formatter.format('transparentColorName', {
lightness,
chroma,
hue,
percentTransparent
}).replace(/\s+/g, ' ').trim();
} else {
return formatter.format('colorName', {
lightness,
chroma,
hue
}).replace(/\s+/g, ' ').trim();
}
}
private getOklchHue(l: number, c: number, h: number, locale: string): [string, number] {
let strings = LocalizedStringDictionary.getGlobalDictionaryForPackage('@react-stately/color') || dictionary;
if (c < GRAY_THRESHOLD) {
return [strings.getStringForLocale('gray', locale), l];
}
for (let i = 0; i < OKLCH_HUES.length; i++) {
let [hue, hueName] = OKLCH_HUES[i];
let [nextHue, nextHueName] = OKLCH_HUES[i + 1] || [360, 'pink'];
if (h >= hue && h < nextHue) {
// Split orange hue into brown/orange depending on lightness.
if (hueName === 'orange') {
if (l < ORANGE_LIGHTNESS_THRESHOLD) {
hueName = 'brown';
} else {
// Adjust lightness.
l = (l - ORANGE_LIGHTNESS_THRESHOLD) + MAX_DARK_LIGHTNESS;
}
}
// If the hue is at least halfway to the next hue, add the next hue name as well.
if (h > hue + (nextHue - hue) / 2 && hueName !== nextHueName) {
hueName = `${hueName} ${nextHueName}`;
} else if (hueName === 'yellow' && l < YELLOW_GREEN_LIGHTNESS_THRESHOLD) {
// Yellow shifts toward green at lower lightnesses.
hueName = 'yellow green';
}
let name = strings.getStringForLocale(hueName, locale).toLocaleLowerCase(locale);
return [name, l];
}
}
throw new Error('Unexpected hue');
}
getHueName(locale: string): string {
let [l, c, h] = toOKLCH(this);
let [name] = this.getOklchHue(l, c, h, locale);
return name;
}
}
class RGBColor extends Color {
constructor(private red: number, private green: number, private blue: number, private alpha: number) {
super();
}
static parse(value: string) {
let colors: Array<number | undefined> = [];
// matching #rgb, #rgba, #rrggbb, #rrggbbaa
if (/^#[\da-f]+$/i.test(value) && [4, 5, 7, 9].includes(value.length)) {
const values = (value.length < 6 ? value.replace(/[^#]/gi, '$&$&') : value).slice(1).split('');
while (values.length > 0) {
colors.push(parseInt(values.splice(0, 2).join(''), 16));
}
colors[3] = colors[3] !== undefined ? colors[3] / 255 : undefined;
}
// matching rgb(rrr, ggg, bbb), rgba(rrr, ggg, bbb, 0.a)
const match = value.match(/^rgba?\((.*)\)$/);
if (match?.[1]) {
colors = match[1].split(',').map(value => Number(value.trim()));
colors = colors.map((num, i) => {
return clamp(num ?? 0, 0, i < 3 ? 255 : 1);
});
}
if (colors[0] === undefined || colors[1] === undefined || colors[2] === undefined) {
return undefined;
}
return colors.length < 3 ? undefined : new RGBColor(colors[0], colors[1], colors[2], colors[3] ?? 1);
}
toString(format: ColorFormat | 'css' = 'css') {
switch (format) {
case 'hex':
return '#' + (this.red.toString(16).padStart(2, '0') + this.green.toString(16).padStart(2, '0') + this.blue.toString(16).padStart(2, '0')).toUpperCase();
case 'hexa':
return '#' + (this.red.toString(16).padStart(2, '0') + this.green.toString(16).padStart(2, '0') + this.blue.toString(16).padStart(2, '0') + Math.round(this.alpha * 255).toString(16).padStart(2, '0')).toUpperCase();
case 'rgb':
return `rgb(${this.red}, ${this.green}, ${this.blue})`;
case 'css':
case 'rgba':
return `rgba(${this.red}, ${this.green}, ${this.blue}, ${this.alpha})`;
default:
return this.toFormat(format).toString(format);
}
}
toFormat(format: ColorFormat): IColor {
switch (format) {
case 'hex':
case 'hexa':
case 'rgb':
case 'rgba':
return this;
case 'hsb':
case 'hsba':
return this.toHSB();
case 'hsl':
case 'hsla':
return this.toHSL();
default:
throw new Error('Unsupported color conversion: rgb -> ' + format);
}
}
toHexInt(): number {
return this.red << 16 | this.green << 8 | this.blue;
}
/**
* Converts an RGB color value to HSB.
* Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB.
* @returns An HSBColor object.
*/
private toHSB(): IColor {
const red = this.red / 255;
const green = this.green / 255;
const blue = this.blue / 255;
const min = Math.min(red, green, blue);
const brightness = Math.max(red, green, blue);
const chroma = brightness - min;
const saturation = brightness === 0 ? 0 : chroma / brightness;
let hue = 0; // achromatic
if (chroma !== 0) {
switch (brightness) {
case red:
hue = (green - blue) / chroma + (green < blue ? 6 : 0);
break;
case green:
hue = (blue - red) / chroma + 2;
break;
case blue:
hue = (red - green) / chroma + 4;
break;
}
hue /= 6;
}
return new HSBColor(
toFixedNumber(hue * 360, 2),
toFixedNumber(saturation * 100, 2),
toFixedNumber(brightness * 100, 2),
this.alpha
);
}
/**
* Converts an RGB color value to HSL.
* Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB.
* @returns An HSLColor object.
*/
private toHSL(): IColor {
const red = this.red / 255;
const green = this.green / 255;
const blue = this.blue / 255;
const min = Math.min(red, green, blue);
const max = Math.max(red, green, blue);
const lightness = (max + min) / 2;
const chroma = max - min;
let hue: number;
let saturation: number;
if (chroma === 0) {
hue = saturation = 0; // achromatic
} else {
saturation = chroma / (lightness < .5 ? max + min : 2 - max - min);
switch (max) {
case red:
hue = (green - blue) / chroma + (green < blue ? 6 : 0);
break;
case green:
hue = (blue - red) / chroma + 2;
break;
case blue:
default:
hue = (red - green) / chroma + 4;
break;
}
hue /= 6;
}
return new HSLColor(
toFixedNumber(hue * 360, 2),
toFixedNumber(saturation * 100, 2),
toFixedNumber(lightness * 100, 2),
this.alpha);
}
clone(): IColor {
return new RGBColor(this.red, this.green, this.blue, this.alpha);
}
getChannelRange(channel: ColorChannel): ColorChannelRange {
switch (channel) {
case 'red':
case 'green':
case 'blue':
return {minValue: 0x0, maxValue: 0xFF, step: 0x1, pageSize: 0x11};
case 'alpha':
return {minValue: 0, maxValue: 1, step: 0.01, pageSize: 0.1};
default:
throw new Error('Unknown color channel: ' + channel);
}
}
getChannelFormatOptions(channel: ColorChannel): Intl.NumberFormatOptions {
switch (channel) {
case 'red':
case 'green':
case 'blue':
return {style: 'decimal'};
case 'alpha':
return {style: 'percent'};
default:
throw new Error('Unknown color channel: ' + channel);
}
}
formatChannelValue(channel: ColorChannel, locale: string) {
let options = this.getChannelFormatOptions(channel);
let value = this.getChannelValue(channel);
return new NumberFormatter(locale, options).format(value);
}
getColorSpace(): ColorSpace {
return 'rgb';
}
static colorChannels: [ColorChannel, ColorChannel, ColorChannel] = ['red', 'green', 'blue'];
getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] {
return RGBColor.colorChannels;
}
}
// X = <negative/positive number with/without decimal places>
// before/after a comma, 0 or more whitespaces are allowed
// - hsb(X, X%, X%)
// - hsba(X, X%, X%, X)
const HSB_REGEX = /hsb\(([-+]?\d+(?:.\d+)?\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d+(?:.\d+)?%)\)|hsba\(([-+]?\d+(?:.\d+)?\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d(.\d+)?)\)/;
class HSBColor extends Color {
constructor(private hue: number, private saturation: number, private brightness: number, private alpha: number) {
super();
}
static parse(value: string): HSBColor | void {
let m: RegExpMatchArray | null;
if ((m = value.match(HSB_REGEX))) {
const [h, s, b, a] = (m[1] ?? m[2]).split(',').map(n => Number(n.trim().replace('%', '')));
return new HSBColor(normalizeHue(h), clamp(s, 0, 100), clamp(b, 0, 100), clamp(a ?? 1, 0, 1));
}
}
toString(format: ColorFormat | 'css' = 'css') {
switch (format) {
case 'css':
return this.toHSL().toString('css');
case 'hex':
return this.toRGB().toString('hex');
case 'hexa':
return this.toRGB().toString('hexa');
case 'hsb':
return `hsb(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.brightness, 2)}%)`;
case 'hsba':
return `hsba(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.brightness, 2)}%, ${this.alpha})`;
default:
return this.toFormat(format).toString(format);
}
}
toFormat(format: ColorFormat): IColor {
switch (format) {
case 'hsb':
case 'hsba':
return this;
case 'hsl':
case 'hsla':
return this.toHSL();
case 'rgb':
case 'rgba':
return this.toRGB();
default:
throw new Error('Unsupported color conversion: hsb -> ' + format);
}
}
/**
* Converts a HSB color to HSL.
* Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_HSL.
* @returns An HSLColor object.
*/
private toHSL(): IColor {
let saturation = this.saturation / 100;
let brightness = this.brightness / 100;
let lightness = brightness * (1 - saturation / 2);
saturation = lightness === 0 || lightness === 1 ? 0 : (brightness - lightness) / Math.min(lightness, 1 - lightness);
return new HSLColor(
toFixedNumber(this.hue, 2),
toFixedNumber(saturation * 100, 2),
toFixedNumber(lightness * 100, 2),
this.alpha
);
}
/**
* Converts a HSV color value to RGB.
* Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB_alternative.
* @returns An RGBColor object.
*/
private toRGB(): IColor {
let hue = this.hue;
let saturation = this.saturation / 100;
let brightness = this.brightness / 100;
let fn = (n: number, k = (n + hue / 60) % 6) => brightness - saturation * brightness * Math.max(Math.min(k, 4 - k, 1), 0);
return new RGBColor(
Math.round(fn(5) * 255),
Math.round(fn(3) * 255),
Math.round(fn(1) * 255),
this.alpha
);
}
clone(): IColor {
return new HSBColor(this.hue, this.saturation, this.brightness, this.alpha);
}
getChannelRange(channel: ColorChannel): ColorChannelRange {
switch (channel) {
case 'hue':
return {minValue: 0, maxValue: 360, step: 1, pageSize: 15};
case 'saturation':
case 'brightness':
return {minValue: 0, maxValue: 100, step: 1, pageSize: 10};
case 'alpha':
return {minValue: 0, maxValue: 1, step: 0.01, pageSize: 0.1};
default:
throw new Error('Unknown color channel: ' + channel);
}
}
getChannelFormatOptions(channel: ColorChannel): Intl.NumberFormatOptions {
switch (channel) {
case 'hue':
return {style: 'unit', unit: 'degree', unitDisplay: 'narrow'};
case 'saturation':
case 'brightness':
case 'alpha':
return {style: 'percent'};
default:
throw new Error('Unknown color channel: ' + channel);
}
}
formatChannelValue(channel: ColorChannel, locale: string) {
let options = this.getChannelFormatOptions(channel);
let value = this.getChannelValue(channel);
if (channel === 'saturation' || channel === 'brightness') {
value /= 100;
}
return new NumberFormatter(locale, options).format(value);
}
getColorSpace(): ColorSpace {
return 'hsb';
}
static colorChannels: [ColorChannel, ColorChannel, ColorChannel] = ['hue', 'saturation', 'brightness'];
getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] {
return HSBColor.colorChannels;
}
}
// X = <negative/positive number with/without decimal places>
// before/after a comma, 0 or more whitespaces are allowed
// - hsl(X, X%, X%)
// - hsla(X, X%, X%, X)
const HSL_REGEX = /hsl\(([-+]?\d+(?:.\d+)?\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d+(?:.\d+)?%)\)|hsla\(([-+]?\d+(?:.\d+)?\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d+(?:.\d+)?%\s*,\s*[-+]?\d(.\d+)?)\)/;
class HSLColor extends Color {
constructor(private hue: number, private saturation: number, private lightness: number, private alpha: number) {
super();
}
static parse(value: string): HSLColor | void {
let m: RegExpMatchArray | null;
if ((m = value.match(HSL_REGEX))) {
const [h, s, l, a] = (m[1] ?? m[2]).split(',').map(n => Number(n.trim().replace('%', '')));
return new HSLColor(normalizeHue(h), clamp(s, 0, 100), clamp(l, 0, 100), clamp(a ?? 1, 0, 1));
}
}
toString(format: ColorFormat | 'css' = 'css') {
switch (format) {
case 'hex':
return this.toRGB().toString('hex');
case 'hexa':
return this.toRGB().toString('hexa');
case 'hsl':
return `hsl(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.lightness, 2)}%)`;
case 'css':
case 'hsla':
return `hsla(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.lightness, 2)}%, ${this.alpha})`;
default:
return this.toFormat(format).toString(format);
}
}
toFormat(format: ColorFormat): IColor {
switch (format) {
case 'hsl':
case 'hsla':
return this;
case 'hsb':
case 'hsba':
return this.toHSB();
case 'rgb':
case 'rgba':
return this.toRGB();
default:
throw new Error('Unsupported color conversion: hsl -> ' + format);
}
}
/**
* Converts a HSL color to HSB.
* Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_HSV.
* @returns An HSBColor object.
*/
private toHSB(): IColor {
let saturation = this.saturation / 100;
let lightness = this.lightness / 100;
let brightness = lightness + saturation * Math.min(lightness, 1 - lightness);
saturation = brightness === 0 ? 0 : 2 * (1 - lightness / brightness);
return new HSBColor(
toFixedNumber(this.hue, 2),
toFixedNumber(saturation * 100, 2),
toFixedNumber(brightness * 100, 2),
this.alpha
);
}
/**
* Converts a HSL color to RGB.
* Conversion formula adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB_alternative.
* @returns An RGBColor object.
*/
private toRGB(): IColor {
let hue = this.hue;
let saturation = this.saturation / 100;
let lightness = this.lightness / 100;
let a = saturation * Math.min(lightness, 1 - lightness);
let fn = (n: number, k = (n + hue / 30) % 12) => lightness - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return new RGBColor(
Math.round(fn(0) * 255),
Math.round(fn(8) * 255),
Math.round(fn(4) * 255),
this.alpha
);
}
clone(): IColor {
return new HSLColor(this.hue, this.saturation, this.lightness, this.alpha);
}
getChannelRange(channel: ColorChannel): ColorChannelRange {
switch (channel) {
case 'hue':
return {minValue: 0, maxValue: 360, step: 1, pageSize: 15};
case 'saturation':
case 'lightness':
return {minValue: 0, maxValue: 100, step: 1, pageSize: 10};
case 'alpha':
return {minValue: 0, maxValue: 1, step: 0.01, pageSize: 0.1};
default:
throw new Error('Unknown color channel: ' + channel);
}
}
getChannelFormatOptions(channel: ColorChannel): Intl.NumberFormatOptions {
switch (channel) {
case 'hue':
return {style: 'unit', unit: 'degree', unitDisplay: 'narrow'};
case 'saturation':
case 'lightness':
case 'alpha':
return {style: 'percent'};
default:
throw new Error('Unknown color channel: ' + channel);
}
}
formatChannelValue(channel: ColorChannel, locale: string) {
let options = this.getChannelFormatOptions(channel);
let value = this.getChannelValue(channel);
if (channel === 'saturation' || channel === 'lightness') {
value /= 100;
}
return new NumberFormatter(locale, options).format(value);
}
getColorSpace(): ColorSpace {
return 'hsl';
}
static colorChannels: [ColorChannel, ColorChannel, ColorChannel] = ['hue', 'saturation', 'lightness'];
getColorChannels(): [ColorChannel, ColorChannel, ColorChannel] {
return HSLColor.colorChannels;
}
}
// https://www.w3.org/TR/css-color-4/#color-conversion-code
function toOKLCH(color: Color) {
let rgb = color.toFormat('rgb');
let red = rgb.getChannelValue('red') / 255;
let green = rgb.getChannelValue('green') / 255;
let blue = rgb.getChannelValue('blue') / 255;
[red, green, blue] = lin_sRGB(red, green, blue);
let [x, y, z] = lin_sRGB_to_XYZ(red, green, blue);
let [l, a, b] = XYZ_to_OKLab(x, y, z);
return OKLab_to_OKLCH(l, a, b);
}
function OKLab_to_OKLCH(l: number, a: number, b: number): [number, number, number] {
var hue = Math.atan2(b, a) * 180 / Math.PI;
return [
l,
Math.sqrt(a ** 2 + b ** 2), // Chroma
hue >= 0 ? hue : hue + 360 // Hue, in degrees [0 to 360)
];
}
function lin_sRGB(r: number, g: number, b: number): [number, number, number] {
// convert an array of sRGB values
// where in-gamut values are in the range [0 - 1]
// to linear light (un-companded) form.
// https://en.wikipedia.org/wiki/SRGB
// Extended transfer function:
// for negative values, linear portion is extended on reflection of axis,
// then reflected power function is used.
return [lin_sRGB_component(r), lin_sRGB_component(g), lin_sRGB_component(b)];
}
function lin_sRGB_component(val: number) {
let sign = val < 0 ? -1 : 1;
let abs = Math.abs(val);
if (abs <= 0.04045) {
return val / 12.92;
}
return sign * (Math.pow((abs + 0.055) / 1.055, 2.4));
}
function lin_sRGB_to_XYZ(r: number, g: number, b: number) {
// convert an array of linear-light sRGB values to CIE XYZ
// using sRGB's own white, D65 (no chromatic adaptation)
const M = [
506752 / 1228815, 87881 / 245763, 12673 / 70218,
87098 / 409605, 175762 / 245763, 12673 / 175545,
7918 / 409605, 87881 / 737289, 1001167 / 1053270
];
return multiplyMatrix(M, r, g, b);
}
function XYZ_to_OKLab(x: number, y: number, z: number) {
// Given XYZ relative to D65, convert to OKLab
const XYZtoLMS = [
0.8190224379967030, 0.3619062600528904, -0.1288737815209879,
0.0329836539323885, 0.9292868615863434, 0.0361446663506424,
0.0481771893596242, 0.2642395317527308, 0.6335478284694309
];
const LMStoOKLab = [
0.2104542683093140, 0.7936177747023054, -0.0040720430116193,
1.9779985324311684, -2.4285922420485799, 0.4505937096174110,
0.0259040424655478, 0.7827717124575296, -0.8086757549230774
];
let [a, b, c] = multiplyMatrix(XYZtoLMS, x, y, z);
return multiplyMatrix(LMStoOKLab, Math.cbrt(a), Math.cbrt(b), Math.cbrt(c));
}
function multiplyMatrix(m: number[], x: number, y: number, z: number): [number, number, number] {
let a = m[0] * x + m[1] * y + m[2] * z;
let b = m[3] * x + m[4] * y + m[5] * z;
let c = m[6] * x + m[7] * y + m[8] * z;
return [a, b, c];
}