UNPKG

@js-draw/math

Version:
423 lines (422 loc) 15.7 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Color4 = void 0; const Vec3_1 = __importDefault(require("./Vec3")); /** * Represents a color. * * @example * ```ts,runnable,console * import { Color4 } from '@js-draw/math'; * * console.log('Red:', Color4.fromString('#f00')); * console.log('Also red:', Color4.ofRGB(1, 0, 0), Color4.red); * console.log('Mixing red and blue:', Color4.red.mix(Color4.blue, 0.5)); * console.log('To string:', Color4.orange.toHexString()); * ``` */ class Color4 { constructor( /** Red component. Should be in the range [0, 1]. */ r, /** Green component. ${\tt g} \in [0, 1]$ */ g, /** Blue component. ${\tt b} \in [0, 1]$ */ b, /** Alpha/transparent component. ${\tt a} \in [0, 1]$. 0 = transparent */ a) { this.r = r; this.g = g; this.b = b; this.a = a; this.hexString = null; } /** * Create a color from red, green, blue components. The color is fully opaque (`a = 1.0`). * * Each component should be in the range [0, 1]. */ static ofRGB(red, green, blue) { return Color4.ofRGBA(red, green, blue, 1.0); } /** * Creates a color from red, green, blue, and transparency components. Each component should * be in the range $[0, 1]$. */ static ofRGBA(red, green, blue, alpha) { red = Math.max(0, Math.min(red, 1)); green = Math.max(0, Math.min(green, 1)); blue = Math.max(0, Math.min(blue, 1)); alpha = Math.max(0, Math.min(alpha, 1)); return new Color4(red, green, blue, alpha); } /** * Creates a color from an RGB (or RGBA) array. * * This is similar to {@link ofRGB} and {@link ofRGBA}, but, by default, takes values * that range from 0 to 255. * * If the array values instead range from 0-1, pass `maxValue` as `1`. */ static fromRGBArray(array, maxValue = 255) { const red = array[0]; const green = array[1] ?? red; const blue = array[2] ?? red; let alpha = 255; if (3 < array.length) { alpha = array[3]; } return Color4.ofRGBA(red / maxValue, green / maxValue, blue / maxValue, alpha / maxValue); } /** * Creates a `Color4` from a three or four-component hexadecimal * [color string](https://en.wikipedia.org/wiki/Web_colors#Hex_triplet). * * Example: * ```ts,runnable,console * import { Color4 } from '@js-draw/math'; * console.log(Color4.fromHex('#ff0')); * ``` */ static fromHex(hexString) { // Remove starting '#' (if present) hexString = (hexString.match(/^[#]?(.*)$/) ?? [])[1]; hexString = hexString.toUpperCase(); if (!hexString.match(/^[0-9A-F]+$/)) { throw new Error(`${hexString} is not in a valid format.`); } // RGBA or RGB if (hexString.length === 3 || hexString.length === 4) { // Each character is a component const components = hexString.split(''); // Convert to RRGGBBAA or RRGGBB format hexString = components.map((component) => `${component}0`).join(''); } if (hexString.length === 6) { // Alpha component hexString += 'FF'; } const components = []; for (let i = 2; i <= hexString.length; i += 2) { const chunk = hexString.substring(i - 2, i); components.push(parseInt(chunk, 16) / 255); } if (components.length !== 4) { throw new Error(`Unable to parse ${hexString}: Wrong number of components.`); } return Color4.ofRGBA(components[0], components[1], components[2], components[3]); } /** Like {@link fromHex}, but can handle additional colors if an `HTMLCanvasElement` is available. */ static fromString(text) { if (text.startsWith('#')) { return Color4.fromHex(text); } if (text === 'none' || text === 'transparent') { return Color4.transparent; } if (text === '') { return Color4.black; } // rgba?: Match both rgb and rgba strings. // ([,0-9.]+): Match any string of only numeric, '.' and ',' characters. const rgbRegex = /^rgba?\(([,0-9.]+)\)$/i; const rgbMatch = text.replace(/\s*/g, '').match(rgbRegex); if (rgbMatch) { const componentsListStr = rgbMatch[1]; const componentsList = JSON.parse(`[ ${componentsListStr} ]`); if (componentsList.length === 3) { return Color4.ofRGB(componentsList[0] / 255, componentsList[1] / 255, componentsList[2] / 255); } else if (componentsList.length === 4) { return Color4.ofRGBA(componentsList[0] / 255, componentsList[1] / 255, componentsList[2] / 255, componentsList[3]); } else { throw new Error(`RGB string, ${text}, has wrong number of components: ${componentsList.length}`); } } // Otherwise, try to use an HTMLCanvasElement to determine the color. // Note: We may be unable to create an HTMLCanvasElement if running as a unit test. const canvas = document.createElement('canvas'); canvas.width = 1; canvas.height = 1; const ctx = canvas.getContext('2d'); // Default to black if no canvas is available. if (!ctx) { return Color4.black; } ctx.fillStyle = text; ctx.fillRect(0, 0, 1, 1); const data = ctx.getImageData(0, 0, 1, 1); const red = data.data[0] / 255; const green = data.data[1] / 255; const blue = data.data[2] / 255; const alpha = data.data[3] / 255; return Color4.ofRGBA(red, green, blue, alpha); } /** @returns true if `this` and `other` are approximately equal. */ eq(other) { if (other == null) { return false; } // If both completely transparent, if (this.a === 0 && other.a === 0) { return true; } return this.toHexString() === other.toHexString(); } /** * If `fractionTo` is not in the range $[0, 1]$, it will be clamped to the nearest number * in that range. For example, `a.mix(b, -1)` is equivalent to `a.mix(b, 0)`. * * @returns a color `fractionTo` of the way from this color to `other`. * * @example * ```ts * Color4.ofRGB(1, 0, 0).mix(Color4.ofRGB(0, 1, 0), 0.1) // -> Color4(0.9, 0.1, 0) * ``` */ mix(other, fractionTo) { fractionTo = Math.min(Math.max(fractionTo, 0), 1); const fractionOfThis = 1 - fractionTo; return new Color4(this.r * fractionOfThis + other.r * fractionTo, this.g * fractionOfThis + other.g * fractionTo, this.b * fractionOfThis + other.b * fractionTo, this.a * fractionOfThis + other.a * fractionTo); } /** Returns a new color with a different opacity. */ withAlpha(a) { return new Color4(this.r, this.g, this.b, a); } /** * Ignoring this color's alpha component, returns a vector with components, * $$ * \begin{pmatrix} \colorbox{#F44}{\tt r} \\ \colorbox{#4F4}{\tt g} \\ \colorbox{#44F}{\tt b} \end{pmatrix} * $$ */ get rgb() { return Vec3_1.default.of(this.r, this.g, this.b); } /** * Returns the [relative luminance](https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef) * of this color in the sRGB color space. * * Ignores the alpha component. */ relativeLuminance() { // References: // - https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef // - https://stackoverflow.com/a/9733420 // Normalize the components, as per above const components = [this.r, this.g, this.b].map((component) => { if (component < 0.03928) { return component / 12.92; } else { return Math.pow((component + 0.055) / 1.055, 2.4); } }); // From w3.org, // > For the sRGB colorspace, the relative luminance of a color is // > defined as L = 0.2126 * R + 0.7152 * G + 0.0722 * B // where R, G, B are defined in components above. return 0.2126 * components[0] + 0.7152 * components[1] + 0.0722 * components[2]; } /** * Returns the [contrast ratio](https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef) * between `colorA` and `colorB`. */ static contrastRatio(colorA, colorB) { const L1 = colorA.relativeLuminance(); const L2 = colorB.relativeLuminance(); return (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05); } /** * @returns the component-wise average of `colors`, or `Color4.transparent` if `colors` is empty. */ static average(colors) { let averageA = 0; let averageR = 0; let averageG = 0; let averageB = 0; for (const color of colors) { averageA += color.a; averageR += color.r; averageG += color.g; averageB += color.b; } if (colors.length > 0) { averageA /= colors.length; averageR /= colors.length; averageG /= colors.length; averageB /= colors.length; } return new Color4(averageR, averageG, averageB, averageA); } /** * Converts to (hue, saturation, value). * See also https://en.wikipedia.org/wiki/HSL_and_HSV#General_approach * * The resultant hue is represented in radians and is thus in $[0, 2\pi]$. */ asHSV() { // Ref: https://en.wikipedia.org/wiki/HSL_and_HSV#General_approach // // HUE: // First, consider the unit cube. Rotate it such that one vertex is at the origin // of a plane and its three neighboring vertices are equidistant from that plane: // // /\ // / | \ // 2 / 3 \ 1 // \ | / // \ | / // . \/ . // // . // // Let z be up and (x, y, 0) be in the plane. // // Label vectors 1,2,3 with R, G, and B, respectively. Let R's projection into the plane // lie along the x axis. // // Because R is a unit vector and R, G, B are equidistant from the plane, they must // form 30-60-90 triangles, which have side lengths proportional to (1, √3, 2) // // /| // 1/ | (√3)/2 // / | // 1/2 // const minComponent = Math.min(this.r, this.g, this.b); const maxComponent = Math.max(this.r, this.g, this.b); const chroma = maxComponent - minComponent; let hue; // See https://en.wikipedia.org/wiki/HSL_and_HSV#General_approach if (chroma === 0) { hue = 0; } else if (this.r >= this.g && this.r >= this.b) { hue = ((this.g - this.b) / chroma) % 6; } else if (this.g >= this.r && this.g >= this.b) { hue = (this.b - this.r) / chroma + 2; } else { hue = (this.r - this.g) / chroma + 4; } // Convert to degree representation, then to radians. hue *= 60; hue *= Math.PI / 180; // Ensure positivity. if (hue < 0) { hue += Math.PI * 2; } const value = maxComponent; const saturation = value > 0 ? chroma / value : 0; return Vec3_1.default.of(hue, saturation, value); } /** * Creates a new `Color4` from a representation [in $HSV$](https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB). * * [Algorithm](https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB). * * Note that hue must be given **in radians**. While non-standard, this is consistent with * {@link asHSV}. * * `hue` and `value` should range from 0 to 1. * * @param hue $H \in [0, 2\pi]$ * @param saturation $S_V \in [0, 1]$ * @param value $V \in [0, 1]$ */ static fromHSV(hue, saturation, value) { if (hue < 0) { hue += Math.PI * 2; } hue %= Math.PI * 2; // Clamp value and saturation to [0, 1] value = Math.max(0, Math.min(1, value)); saturation = Math.max(0, Math.min(1, saturation)); // Formula from https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB // Saturation can be thought of as scaled chroma. Unapply the scaling. // See https://en.wikipedia.org/wiki/HSL_and_HSV#Saturation const chroma = value * saturation; // Determines which edge of the projected color cube const huePrime = hue / (Math.PI / 3); const secondLargestComponent = chroma * (1 - Math.abs((huePrime % 2) - 1)); let rgb; if (huePrime < 1) { rgb = [chroma, secondLargestComponent, 0]; } else if (huePrime < 2) { rgb = [secondLargestComponent, chroma, 0]; } else if (huePrime < 3) { rgb = [0, chroma, secondLargestComponent]; } else if (huePrime < 4) { rgb = [0, secondLargestComponent, chroma]; } else if (huePrime < 5) { rgb = [secondLargestComponent, 0, chroma]; } else { rgb = [chroma, 0, secondLargestComponent]; } const adjustment = value - chroma; return Color4.ofRGB(rgb[0] + adjustment, rgb[1] + adjustment, rgb[2] + adjustment); } /** * Equivalent to `ofRGB(rgb.x, rgb.y, rgb.z)`. * * All components should be in the range `[0, 1]` (0 to 1 inclusive). */ static fromRGBVector(rgb, alpha) { return Color4.ofRGBA(rgb.x, rgb.y, rgb.z, alpha ?? 1); } /** * @returns a hexadecimal color string representation of `this`, in the form `#rrggbbaa`. * * @example * ``` * Color4.red.toHexString(); // -> #ff0000ff * ``` */ toHexString() { if (this.hexString) { return this.hexString; } const componentToHex = (component) => { const res = Math.round(255 * component).toString(16); if (res.length === 1) { return `0${res}`; } return res; }; const alpha = componentToHex(this.a); const red = componentToHex(this.r); const green = componentToHex(this.g); const blue = componentToHex(this.b); if (alpha === 'ff') { return `#${red}${green}${blue}`; } this.hexString = `#${red}${green}${blue}${alpha}`; return this.hexString; } toString() { return this.toHexString(); } } exports.Color4 = Color4; Color4.transparent = Color4.ofRGBA(0, 0, 0, 0); Color4.red = Color4.ofRGB(1.0, 0.0, 0.0); Color4.orange = Color4.ofRGB(1.0, 0.65, 0.0); Color4.green = Color4.ofRGB(0.0, 1.0, 0.0); Color4.blue = Color4.ofRGB(0.0, 0.0, 1.0); Color4.purple = Color4.ofRGB(0.5, 0.2, 0.5); Color4.yellow = Color4.ofRGB(1, 1, 0.1); Color4.clay = Color4.ofRGB(0.8, 0.4, 0.2); Color4.black = Color4.ofRGB(0, 0, 0); Color4.gray = Color4.ofRGB(0.5, 0.5, 0.5); Color4.white = Color4.ofRGB(1, 1, 1); exports.default = Color4;