UNPKG

@kobalte/core

Version:

Unstyled components and primitives for building accessible web apps and design systems with SolidJS.

906 lines (812 loc) 24.3 kB
/* * Portions of this file are based on code from react-spectrum. * Apache License Version 2.0, Copyright 2020 Adobe. * * Credits to the React Spectrum team: * https://github.com/adobe/react-spectrum/blob/68e305768cb829bab7b9836dded593bd731259f3/packages/%40react-stately/color/src/Color.ts * */ import { clamp } from "@kobalte/utils"; import { createNumberFormatter } from "../i18n"; import type { ColorIntlTranslations } from "./intl"; import type { ColorAxes, ColorChannel, ColorChannelRange, ColorFormat, ColorSpace, Color as IColor, } from "./types"; /** Parses a color from a string value. Throws an error if the string could not be parsed. */ export function parseColor(value: string): IColor { const 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); } 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): string; toHexInt(): number { return this.toFormat("rgb").toHexInt(); } getChannelValue(channel: ColorChannel): number { if (channel in this) { //@ts-expect-error return this[channel]; } throw new Error(`Unsupported color channel: ${channel}`); } withChannelValue(channel: ColorChannel, value: number): IColor { if (channel in this) { const x = this.clone(); //@ts-expect-error x[channel] = value; return x; } throw new Error(`Unsupported color channel: ${channel}`); } getChannelName(channel: ColorChannel, translations: ColorIntlTranslations) { return translations[channel]; } abstract getColorSpace(): ColorSpace; getColorSpaceAxes(xyChannels: { xChannel?: ColorChannel; yChannel?: ColorChannel; }): ColorAxes { const { xChannel, yChannel } = xyChannels; const xCh = xChannel || this.getColorChannels().find((c) => c !== yChannel)!; const yCh = yChannel || this.getColorChannels().find((c) => c !== xCh)!; const zCh = this.getColorChannels().find((c) => c !== xCh && c !== yCh)!; return { xChannel: xCh, yChannel: yCh, zChannel: zCh }; } abstract getColorChannels(): [ColorChannel, ColorChannel, ColorChannel]; getColorName(translations: ColorIntlTranslations): string { // Convert to oklch color space, which has perceptually uniform lightness across all hues. let [l, c, h] = toOKLCH(this); if (l > 0.999) { return translations.white; } if (l < 0.001) { return translations.black; } let hue: string; [hue, l] = this.getOklchHue(l, c, h, translations); 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) { //@ts-expect-error chroma = translations[chroma]; } if (lightness) { //@ts-expect-error lightness = translations[lightness]; } const alpha = this.getChannelValue("alpha"); if (alpha < 1) { const percentTransparent = createNumberFormatter(() => ({ style: "percent", }))().format(1 - alpha); return translations .transparentColorName(lightness, chroma, hue, percentTransparent) .replace(/\s+/g, " ") .trim(); } return translations .colorName(lightness, chroma, hue) .replace(/\s+/g, " ") .trim(); } private getOklchHue( l: number, c: number, h: number, translations: ColorIntlTranslations, ): [string, number] { if (c < GRAY_THRESHOLD) { return [translations.gray, l]; } for (let i = 0; i < OKLCH_HUES.length; i++) { let [hue, hueName] = OKLCH_HUES[i]; const [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. // biome-ignore lint/style/noParameterAssign: 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"; } //@ts-expect-error const name = translations[hueName]; return [name, l]; } } throw new Error("Unexpected hue"); } getHueName(translations: ColorIntlTranslations): string { const [l, c, h] = toOKLCH(this); const [name] = this.getOklchHue(l, c, h, translations); 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(Number.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), rgb(rrr ggg bbb), rgba(rrr ggg bbb a) const match = value.match(/^rgba?\((.*)\)$/); if (match?.[1]) { colors = match[1] .replace(/(\d+)%$/u, (_substring, numberValue) => (Number(numberValue) / 100).toString(), ) .replaceAll(/,|\//gu, " ") .replaceAll(/\s{2,}/gu, " ") .split(" ") .map((value) => { return 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 "css": case "rgb": return `rgb(${this.red}, ${this.green}, ${this.blue}${this.alpha !== 1 && this.alpha !== 100 ? ` / ${this?.alpha}` : ""})`; 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 < 0.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; 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", maximumFractionDigits: 2 }; default: throw new Error(`Unknown color channel: ${channel}`); } } formatChannelValue(channel: ColorChannel) { const options = this.getChannelFormatOptions(channel); const value = this.getChannelValue(channel); return createNumberFormatter(() => 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 | undefined { 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; const brightness = this.brightness / 100; const 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 { const hue = this.hue; const saturation = this.saturation / 100; const brightness = this.brightness / 100; const 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", maximumFractionDigits: 2 }; default: throw new Error(`Unknown color channel: ${channel}`); } } formatChannelValue(channel: ColorChannel) { const options = this.getChannelFormatOptions(channel); let value = this.getChannelValue(channel); if (channel === "saturation" || channel === "brightness") { value /= 100; } return createNumberFormatter(() => 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 | undefined { 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)}%${this.alpha !== 1 && this.alpha !== 100 ? ` / ${this.alpha}` : ""})`; 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; const lightness = this.lightness / 100; const 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 { const hue = this.hue; const saturation = this.saturation / 100; const lightness = this.lightness / 100; const a = saturation * Math.min(lightness, 1 - lightness); const 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", maximumFractionDigits: 2 }; default: throw new Error(`Unknown color channel: ${channel}`); } } formatChannelValue(channel: ColorChannel) { const options = this.getChannelFormatOptions(channel); let value = this.getChannelValue(channel); if (channel === "saturation" || channel === "lightness") { value /= 100; } return createNumberFormatter(() => 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) { const 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); const [x, y, z] = lin_sRGB_to_XYZ(red, green, blue); const [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] { const 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) { const sign = val < 0 ? -1 : 1; const abs = Math.abs(val); if (abs <= 0.04045) { return val / 12.92; } return sign * ((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.819022437996703, 0.3619062600528904, -0.1288737815209879, 0.0329836539323885, 0.9292868615863434, 0.0361446663506424, 0.0481771893596242, 0.2642395317527308, 0.6335478284694309, ]; const LMStoOKLab = [ 0.210454268309314, 0.7936177747023054, -0.0040720430116193, 1.9779985324311684, -2.4285922420485799, 0.450593709617411, 0.0259040424655478, 0.7827717124575296, -0.8086757549230774, ]; const [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] { const a = m[0] * x + m[1] * y + m[2] * z; const b = m[3] * x + m[4] * y + m[5] * z; const c = m[6] * x + m[7] * y + m[8] * z; return [a, b, c]; } function toFixedNumber(value: number, digits: number, base = 10): number { const pow = base ** digits; return Math.round(value * pow) / pow; }