diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..08d6d29d7 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "printWidth": 120 +} diff --git a/core/visual/color/base-color.js b/core/visual/color/base-color.js new file mode 100644 index 000000000..3a725f31d --- /dev/null +++ b/core/visual/color/base-color.js @@ -0,0 +1,673 @@ +/** + * Base class for color representations with alpha transparency support. + * Provides common utilities for color validation, alpha channel manipulation, + * and color conversion algorithms used by all color format implementations. + * + * This abstract class serves as the foundation for specific color format classes + * like RgbColor, HslColor, etc., providing shared functionality and validation methods. + * + * **Important:** This class is not intended to be instantiated directly. + * Always use concrete subclasses for color creation and manipulation. + * + * @class BaseColor + * @abstract + * @see {@link RgbColor} RGB color implementation + * @see {@link HslColor} HSL color implementation + * @example + * // ❌ Don't instantiate BaseColor directly: + * // const color = new BaseColor(0.5); + * + * // ✅ Instead, use a concrete subclass: + * const rgbColor = new RgbColor(255, 0, 0, 0.5); + * const hslColor = new HslColor(0, 100, 50, 0.5); + */ +exports.BaseColor = class BaseColor { + /** + * Internal storage for the alpha component value. + * @private + * @type {number} + */ + #alpha = 1; + + /** + * Sets the alpha component value with validation. + * Alpha controls the transparency level of the color. + * @param {number} value - Alpha value (0-1, where 0=transparent, 1=opaque) + * @throws {Error} If alpha is not a number or not between 0 and 1 + */ + set alpha(value) { + if (typeof value !== "number" || !BaseColor.isValidAlphaValue(value)) { + throw new Error("Alpha value must be between 0 and 1"); + } + this.#alpha = value; + } + + /** + * Gets the alpha component value (0-1). + * Alpha controls the transparency level of the color. + * @type {number} + * @readonly + * @default 1 + */ + get alpha() { + return this.#alpha; + } + + /** + * Creates a new BaseColor instance with the specified alpha value. + * Alpha value is automatically clamped to valid range (0-1) for safety. + * + * **Note:** This constructor is intended for use by subclasses only. + * Direct instantiation of BaseColor is not recommended. + * + * @protected + * @param {number} [alpha=1] - Alpha component (0-1, where 0=transparent, 1=opaque) + * @throws {Error} If alpha is not a number + */ + constructor(alpha = 1) { + this.alpha = alpha; + } + + /** + * Validates if a value is within the valid hue range (0-360 degrees). + * Hue represents the position on the color wheel in degrees. + * + * @static + * @param {number} value - The value to validate + * @returns {boolean} True if the value is valid for hue component, false otherwise + */ + static isValidHue(value) { + return typeof value === "number" && value >= 0 && value <= 360; + } + + /** + * Validates if a value is within the valid saturation range (0-100 percent). + * Saturation represents the intensity or purity of the color. + * + * @static + * @param {number} value - The value to validate + * @returns {boolean} True if the value is valid for saturation component, false otherwise + * @example + * BaseColor.isValidSaturation(0); // true (grayscale) + * BaseColor.isValidSaturation(50); // true (moderate saturation) + * BaseColor.isValidSaturation(100); // true (full saturation) + * BaseColor.isValidSaturation(150); // false (out of range) + * BaseColor.isValidSaturation(-5); // false (negative) + */ + static isValidSaturation(value) { + return typeof value === "number" && value >= 0 && value <= 100; + } + + /** + * Validates if a value is within the valid lightness range (0-100 percent). + * Lightness represents how light or dark the color appears. + * + * @static + * @param {number} value - The value to validate + * @returns {boolean} True if the value is valid for lightness component, false otherwise + */ + static isValidLightness(value) { + return typeof value === "number" && value >= 0 && value <= 100; + } + + /** + * Validates if a value is within the valid RGB component range (0-255). + * RGB components represent red, green, and blue intensity values. + * + * @static + * @param {number} value - The value to validate + * @returns {boolean} True if the value is valid for RGB components, false otherwise + */ + static isValidRgbValue(value) { + return typeof value === "number" && value >= 0 && value <= 255; + } + + /** + * Validates if a value is within the valid alpha range (0-1). + * Alpha represents the transparency level of the color. + * + * @static + * @param {number} value - The value to validate + * @returns {boolean} True if the value is valid for alpha component, false otherwise + */ + static isValidAlphaValue(value) { + return typeof value === "number" && value >= 0 && value <= 1; + } + + /** + * Checks if a color string represents a transparent color keyword. + * Recognizes CSS transparency keywords and empty strings. + * + * @static + * @param {string} string - The color string to check + * @returns {boolean} True if the string represents transparency, false otherwise + */ + static isValidTransparentKeyword(string) { + if (typeof string !== "string") return false; + const trimmed = string.trim().toLowerCase(); + return trimmed === "transparent" || trimmed === "initial" || trimmed === "inherit" || !trimmed; + } + + /** + * Checks if a string is a valid hexadecimal color format. + * Supports all standard hex color formats with optional '#' prefix: + * - 3-digit: RGB (e.g., "#f00" or "f00") + * - 4-digit: RGBA (e.g., "#f00f" or "f00f") + * - 6-digit: RRGGBB (e.g., "#ff0000" or "ff0000") + * - 8-digit: RRGGBBAA (e.g., "#ff0000ff" or "ff0000ff") + * + * @static + * @param {string} string - The string to validate + * @returns {boolean} True if the string is a valid hex color, false otherwise + */ + static isValidHexColorString(string) { + if (typeof string !== "string") return false; + const hexColorRegex = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + return hexColorRegex.test(string.trim()); + } + + /** + * Converts an HSL color to RGB format. + * Uses the standard HSL to RGB conversion algorithm. + * + * @static + * @param {number} hue - Hue value (0-360 degrees) + * @param {number} saturation - Saturation value (0-100 percent) + * @param {number} lightness - Lightness value (0-100 percent) + * @returns {{red: number, green: number, blue: number}} An object containing red, green, and blue values (0-255) + * @throws {Error} If any input values are out of their valid ranges + * @see {@link https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB} HSL to RGB Algorithm + */ + static fromHslToRgb(hue, saturation, lightness) { + // Validate input values + if (!BaseColor.isValidHue(hue)) { + throw new Error("Hue value must be between 0 and 360"); + } + if (!BaseColor.isValidSaturation(saturation)) { + throw new Error("Saturation value must be between 0 and 100"); + } + if (!BaseColor.isValidLightness(lightness)) { + throw new Error("Lightness value must be between 0 and 100"); + } + + // Normalize HSL values to 0-1 range + hue = hue / 360; + saturation = saturation / 100; + lightness = lightness / 100; + + // Calculate RGB components + let red; + let green; + let blue; + + if (saturation === 0) { + // Achromatic (grayscale) - no color, only lightness + red = green = blue = lightness; + } else { + // Chromatic (colorful) - calculate using HSL to RGB algorithm + const chroma = + lightness < 0.5 ? lightness * (1 + saturation) : lightness + saturation - lightness * saturation; + const base = 2 * lightness - chroma; + + // Use inherited helper method for hue to RGB conversion + red = this.hueToRgbComponent(base, chroma, hue + 1 / 3); + green = this.hueToRgbComponent(base, chroma, hue); + blue = this.hueToRgbComponent(base, chroma, hue - 1 / 3); + } + + // Convert to 0-255 range and round to integers + return { + red: Math.round(red * 255), + green: Math.round(green * 255), + blue: Math.round(blue * 255), + }; + } + + /** + * Converts an RGB color to HSL format. + * Uses the standard RGB to HSL conversion algorithm. + * + * @static + * @param {number} red - Red component (0-255) + * @param {number} green - Green component (0-255) + * @param {number} blue - Blue component (0-255) + * @returns {{hue: number, saturation: number, lightness: number}} An object containing hue (0-360), saturation (0-100), and lightness (0-100) values + * @throws {Error} If any RGB values are out of the valid range (0-255) + * @see {@link https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB} RGB to HSL Algorithm + */ + static fromRgbToHsl(red, green, blue) { + // Validate input values + if (!BaseColor.isValidRgbValue(red)) { + throw new Error("Red value must be between 0 and 255"); + } + if (!BaseColor.isValidRgbValue(green)) { + throw new Error("Green value must be between 0 and 255"); + } + if (!BaseColor.isValidRgbValue(blue)) { + throw new Error("Blue value must be between 0 and 255"); + } + + // normalize RGB values to 0-1 range + red = red / 255; + green = green / 255; + blue = blue / 255; + + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + + const lightness = (max + min) / 2; + let saturation; + let hue; + + if (max === min) { + hue = saturation = 0; // achromatic + } else { + const delta = max - min; + // Calculate saturation + saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min); + + // Calculate hue + switch (max) { + case red: + hue = (green - blue) / delta + (green < blue ? 6 : 0); + break; + case green: + hue = (blue - red) / delta + 2; + break; + case blue: + hue = (red - green) / delta + 4; + break; + } + + hue /= 6; + } + + return { + hue: hue * 360, + saturation: saturation * 100, + lightness: lightness * 100, + }; + } + + /** + * Parses RGB/RGBA color strings into an object with red, green, blue, and alpha values. + * Supports both rgb() and rgba() CSS function formats with decimal values. + * + * @static + * @param {string} rgbaString - RGB/RGBA string (e.g., "rgb(255, 0, 0)" or "rgba(255, 0, 0, 0.5)") + * @returns {{red: number, green: number, blue: number, alpha: number}} An object containing red, green, blue (0-255), and alpha (0-1) values + * @throws {Error} If the string format is invalid or values are out of range + */ + static fromRgbString(rgbaString) { + // Match rgba(r, g, b, a) or rgb(r, g, b) format with optional decimal values + const rgbaMatch = rgbaString.match( + /rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*(?:,\s*(\d+(?:\.\d+)?))?\s*\)/ + ); + + if (!rgbaMatch) throw new Error("Invalid RGBA/RGB color format"); + + const red = parseFloat(rgbaMatch[1]); + const green = parseFloat(rgbaMatch[2]); + const blue = parseFloat(rgbaMatch[3]); + const alpha = rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1; + + return { red, green, blue, alpha }; + } + + /** + * Parses HSL/HSLA color strings into an object with hue, saturation, lightness, and alpha values. + * Supports both hsl() and hsla() CSS function formats with percentage notation for saturation and lightness. + * + * @static + * @param {string} hslString - HSL/HSLA string (e.g., "hsl(120, 100%, 50%)" or "hsla(120, 100%, 50%, 0.5)") + * @returns {{hue: number, saturation: number, lightness: number, alpha: number}} An object containing hue (0-360), saturation (0-100), lightness (0-100), and alpha (0-1) values + * @throws {Error} If the string format is invalid or values are out of range + */ + static fromHslString(hslString) { + // Match hsla(h, s%, l%, a) or hsl(h, s%, l%) format + const hslaMatch = hslString.match( + /hsla?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)%\s*,\s*(\d+(?:\.\d+)?)%\s*(?:,\s*(\d+(?:\.\d+)?))?\s*\)/ + ); + + if (!hslaMatch) throw new Error("Invalid HSLA/HSL color format"); + + const hue = parseFloat(hslaMatch[1]); + const saturation = parseFloat(hslaMatch[2]); + const lightness = parseFloat(hslaMatch[3]); + const alpha = hslaMatch[4] !== undefined ? parseFloat(hslaMatch[4]) : 1; + + return { hue, saturation, lightness, alpha }; + } + + /** + * Parses a hex color string into an object with red, green, blue, and alpha values. + * Supports all standard hex formats: 3-digit (#RGB), 4-digit (#RGBA), 6-digit (#RRGGBB), and 8-digit (#RRGGBBAA). + * The '#' prefix is optional and whitespace is automatically trimmed. + * + * @static + * @param {string} hexString - Hex color string with optional '#' prefix + * @returns {{red: number, green: number, blue: number, alpha: number}} An object containing red, green, blue (0-255), and alpha (0-1) values + * @throws {Error} If the hex format is invalid + */ + static fromHexString(hexString) { + // Remove # if present and trim whitespace + let hex = hexString.replace("#", "").trim(); + + // Expand 3-digit and 4-digit shorthand hex (e.g., "f00" -> "ff0000", "f00f" -> "ff0000ff") + if (hex.length === 3 || hex.length === 4) { + hex = hex + .split("") + .map((char) => char + char) + .join(""); + } + + // Validate hex length after expansion + if (hex.length !== 6 && hex.length !== 8) { + throw new Error(`Invalid hex color format: ${hexString}`); + } + + try { + let red; + let green; + let blue; + let alpha; + + if (hex.length === 8) { + // Parse 8-digit hex (RRGGBBAA) + red = parseInt(hex.substring(0, 2), 16); + green = parseInt(hex.substring(2, 4), 16); + blue = parseInt(hex.substring(4, 6), 16); + alpha = parseInt(hex.substring(6, 8), 16) / 255; + } else { + // Parse 6-digit hex (RRGGBB) + red = parseInt(hex.substring(0, 2), 16); + green = parseInt(hex.substring(2, 4), 16); + blue = parseInt(hex.substring(4, 6), 16); + alpha = 1; // Default to fully opaque + } + + return { red, green, blue, alpha }; + } catch (e) { + throw new Error(`Invalid hex color value: ${hexString}`); + } + } + + /** + * Helper function to convert hue to RGB component value. + * This is a core part of the HSL to RGB conversion algorithm. + * + * Uses the standard HSL to RGB conversion formula with proper hue wrapping + * and linear interpolation between color segments. + * + * @static + * @protected + * @param {number} base - The base value (minimum RGB component, 0-1) + * @param {number} chroma - The chroma value (maximum RGB component, 0-1) + * @param {number} hueOffset - The hue fraction (0-1) with potential offset for R/G/B calculation + * @returns {number} RGB component value (0-1) + * @see {@link https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB} HSL to RGB Algorithm + */ + static hueToRgbComponent(base, chroma, hueOffset) { + // Wrap hue offset to 0-1 range + if (hueOffset < 0) hueOffset += 1; + if (hueOffset > 1) hueOffset -= 1; + + // Calculate RGB component based on hue position + if (hueOffset < 1 / 6) return base + (chroma - base) * 6 * hueOffset; + if (hueOffset < 1 / 2) return chroma; + if (hueOffset < 2 / 3) return base + (chroma - base) * (2 / 3 - hueOffset) * 6; + + return base; + } + + /** + * Checks if the color is fully transparent (alpha equals 0). + * Useful for determining if a color is invisible and can be optimized out. + * + * @returns {boolean} True if the color is fully transparent, false otherwise + * @example + * const transparentColor = new RgbColor(255, 0, 0, 0); + * transparentColor.isTransparent(); // true + * + * const semiTransparentColor = new RgbColor(255, 0, 0, 0.5); + * semiTransparentColor.isTransparent(); // false + * + * const opaqueColor = new RgbColor(255, 0, 0, 1); + * opaqueColor.isTransparent(); // false + */ + isTransparent() { + return this.alpha === 0; + } + + /** + * Checks if the color is fully opaque (alpha equals 1). + * Useful for determining if alpha channel can be omitted in string representations. + * + * @returns {boolean} True if the color is fully opaque, false otherwise + */ + isOpaque() { + return this.alpha === 1; + } + + /** + * Converts the color to a short hexadecimal string representation if possible. + * Returns 3-digit hex (RGB) or 4-digit hex (RGBA) when all digit pairs are identical. + * Returns null if the color cannot be represented in short format. + * + * **Abstract Method:** Must be implemented by subclasses. + * + * @abstract + * @returns {string|null} Short hex string if possible (e.g., "#f00", "#f00f"), null if not compressible + * @throws {Error} If called directly on BaseColor (must be implemented by subclass) + */ + toShortHexString() { + throw new Error("toShortHexString() must be implemented by subclass"); + } + + /** + * Converts the color to a long hexadecimal string representation. + * Always uses 6-digit hex (RRGGBB) for opaque colors or 8-digit hex (RRGGBBAA) for transparent colors. + * + * **Abstract Method:** Must be implemented by subclasses. + * + * @abstract + * @returns {string} Long hex string (e.g., "#ff0000", "#ff0000ff") + * @throws {Error} If called directly on BaseColor (must be implemented by subclass) + */ + toLongHexString() { + throw new Error("toLongHexString() must be implemented by subclass"); + } + + /** + * Converts the color to the most appropriate hexadecimal string representation. + * Automatically chooses between short and long format, preferring short when possible. + * Uses long format when short format is not available or compression is not possible. + * + * @returns {string} Hex color string in the most appropriate format + */ + toHexString() { + // Try short format first + const shortHex = this.toShortHexString(); + if (shortHex !== null) return shortHex; + + // Fall back to long format + return this.toLongHexString(); + } + + /** + * Helper method to linearize RGB color components for accurate luminance calculation. + * Applies gamma correction according to WCAG 2.0 specification for accessibility compliance. + * + * This transformation is essential for accurate color contrast calculations + * and ensures that luminance values reflect human perception of brightness. + * + * @protected + * @param {number} component - RGB component value (0-255) + * @returns {number} Linearized component value (0-1) after gamma correction + * @see {@link https://www.w3.org/TR/WCAG20/#relativeluminancedef} WCAG 2.0 Relative Luminance Definition + * @see {@link https://en.wikipedia.org/wiki/SRGB} sRGB Gamma Correction + */ + linearizeRgbColorComponent(component) { + // Normalize RGB component to 0-1 range + const normalized = component / 255; + + // Apply gamma correction according to WCAG 2.0 specification + if (normalized <= 0.03928) return normalized / 12.92; + + return Math.pow((normalized + 0.055) / 1.055, 2.4); + } + + /** + * Calculates the relative luminance of the color according to WCAG 2.0. + * Luminance is a measure of the brightness of a color as perceived by the human eye, + * normalized to 0 for darkest black and 1 for lightest white. + * + * This calculation is essential for determining color contrast ratios for accessibility compliance. + * The formula uses the CIE Y component with specific coefficients for red, green, and blue + * that correspond to human visual sensitivity. + * + * @protected + * @param {number} red - Red component (0-255) + * @param {number} green - Green component (0-255) + * @param {number} blue - Blue component (0-255) + * @returns {number} Luminance value between 0 (darkest black) and 1 (brightest white) + * @see {@link https://www.w3.org/TR/WCAG20/#relativeluminancedef} WCAG 2.0 Relative Luminance Definition + * @see {@link https://www.w3.org/TR/WCAG20/#contrast-ratiodef} WCAG 2.0 Contrast Ratio Definition + */ + _getLuminance(red, green, blue) { + // Linearize each RGB component using gamma correction + const R = this.linearizeRgbColorComponent(red); + const G = this.linearizeRgbColorComponent(green); + const B = this.linearizeRgbColorComponent(blue); + + // Apply WCAG 2.0 luminance formula with standard coefficients + return 0.2126 * R + 0.7152 * G + 0.0722 * B; + } + + /** + * Converts the RGB color to a short hexadecimal format if possible. + * Returns 3-digit hex (RGB) or 4-digit hex (RGBA) when all digit pairs are identical. + * Returns null if the color cannot be represented in short format. + * + * This is a helper method used by subclasses to implement their toShortHexString() methods. + * The compression is only possible when each RGB component can be represented by a single + * hex digit repeated twice (e.g., 0x00, 0x11, 0x22, ..., 0xFF). + * + * @protected + * @param {number} red - Red component (0-255) + * @param {number} green - Green component (0-255) + * @param {number} blue - Blue component (0-255) + * @returns {string|null} Short hex string if possible (e.g., "#f00", "#f00f"), null if not compressible + */ + _toShortHexString(red, green, blue) { + const fullHex = this._toLongHexString(red, green, blue); + const hex = fullHex.substring(1); // Remove # prefix + + // Check if it can be compressed to short format + if (hex.length === 6) { + // Check if RRGGBB can become RGB (each pair has identical digits) + if (hex[0] === hex[1] && hex[2] === hex[3] && hex[4] === hex[5]) { + return `#${hex[0]}${hex[2]}${hex[4]}`; + } + } else if (hex.length === 8) { + // Check if RRGGBBAA can become RGBA (each pair has identical digits) + if (hex[0] === hex[1] && hex[2] === hex[3] && hex[4] === hex[5] && hex[6] === hex[7]) { + return `#${hex[0]}${hex[2]}${hex[4]}${hex[6]}`; + } + } + + // Cannot be compressed to short format + return null; + } + + /** + * Converts the RGB color to a long hexadecimal format. + * Always uses 6-digit hex (RRGGBB) for opaque colors or 8-digit hex (RRGGBBAA) for transparent colors. + * RGB values are rounded to the nearest integer before conversion. + * + * This is a helper method used by subclasses to implement their toLongHexString() methods. + * The alpha channel is included only when the color is not fully opaque. + * + * @protected + * @param {number} red - Red component (0-255) + * @param {number} green - Green component (0-255) + * @param {number} blue - Blue component (0-255) + * @returns {string} Long hex string (e.g., "#ff0000" or "#ff0000ff") + */ + _toLongHexString(red, green, blue) { + // Convert RGB components to 2-digit hex, padding with zeros if needed + red = Math.round(red).toString(16).padStart(2, "0"); + green = Math.round(green).toString(16).padStart(2, "0"); + blue = Math.round(blue).toString(16).padStart(2, "0"); + + // For opaque colors, use 6-digit format + if (this.isOpaque()) return `#${red}${green}${blue}`; + + // For transparent colors, add alpha component as 2-digit hex + const alpha = Math.round(this.alpha * 255) + .toString(16) + .padStart(2, "0"); + + return `#${red}${green}${blue}${alpha}`; + } + + /** + * Converts the color to an RGB string representation. + * Uses the format "rgb(red, green, blue)". + * The alpha value is not included in this format. + * + * @protected + * @param {number} red - Red component (0-255) + * @param {number} green - Green component (0-255) + * @param {number} blue - Blue component (0-255) + * @returns {string} RGB string (e.g., "rgb(255, 0, 0)") + */ + _toRgbString(red, green, blue) { + return `rgb(${red}, ${green}, ${blue})`; + } + + /** + * Converts the color to an RGBA string representation. + * Uses the format "rgba(red, green, blue, alpha)". + * The alpha value is included only when the color is not fully opaque. + * + * @protected + * @param {number} red - Red component (0-255) + * @param {number} green - Green component (0-255) + * @param {number} blue - Blue component (0-255) + * @returns {string} RGBA string (e.g., "rgba(255, 0, 0, 0.5)") + */ + _toRgbaString(red, green, blue) { + return `rgba(${red}, ${green}, ${blue}, ${this.alpha})`; + } + + /** + * Converts the color to an HSL string representation. + * Uses the format "hsl(hue, saturation%, lightness%)". + * The alpha value is not included in this format. + * + * @protected + * @param {number} hue - Hue value (0-360 degrees) + * @param {number} saturation - Saturation value (0-100 percent) + * @param {number} lightness - Lightness value (0-100 percent) + * @returns {string} HSL string (e.g., "hsl(120, 100%, 50%)") + */ + _toHslString(hue, saturation, lightness) { + return `hsl(${hue}, ${saturation}%, ${lightness}%)`; + } + + /** + * Converts the color to an HSLA string representation. + * Uses the format "hsla(hue, saturation%, lightness%, alpha)". + * The alpha value is included only when the color is not fully opaque. + * + * @protected + * @param {number} hue - Hue value (0-360 degrees) + * @param {number} saturation - Saturation value (0-100 percent) + * @param {number} lightness - Lightness value (0-100 percent) + * @returns {string} HSLA string (e.g., "hsla(120, 100%, 50%, 0.5)") + */ + _toHslaString(hue, saturation, lightness) { + return `hsla(${hue}, ${saturation}%, ${lightness}%, ${this.alpha})`; + } +}; diff --git a/core/visual/color/hsl-color.js b/core/visual/color/hsl-color.js new file mode 100644 index 000000000..0bd1cf36e --- /dev/null +++ b/core/visual/color/hsl-color.js @@ -0,0 +1,513 @@ +const BaseColor = require("core/visual/color/base-color").BaseColor; + +/** + * Represents a color in HSLA (Hue, Saturation, Lightness, Alpha) format. + * Extends the BaseColor class with HSL-specific functionalities. + * + * HSL is often more intuitive for color manipulation as it separates: + * - Hue: The color itself (0-360 degrees on the color wheel) + * - Saturation: The intensity/purity of the color (0-100%) + * - Lightness: How light or dark the color is (0-100%) + * - Alpha: The transparency level (0-1, where 0 is transparent and 1 is opaque) + * + * HSL is particularly useful for: + * - Creating color variations (lighter/darker, more/less saturated) + * - Color animation and transitions + * - Generating color palettes and themes + * - Accessibility-conscious color adjustments + * + * @class HslColor + * @extends BaseColor + * @see {@link https://en.wikipedia.org/wiki/HSL_and_HSV} HSL Color Model Documentation + * @see {@link https://upload.wikimedia.org/wikipedia/commons/6/6b/HSL_color_solid_cylinder_saturation_gray.png} HSL Color Model Visualization + * @see {@link https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/hsl} MDN HSL Color Reference + * @example + * // Create a red color in HSL + * const red = new HslColor(0, 100, 50, 1); + * + * // Create from HSL string + * const blue = HslColor.fromString("hsl(240, 100%, 50%)"); + * + * // Color manipulation is intuitive with HSL + * const lighterRed = red.copyWithLightness(75); // Make lighter + * const desaturatedRed = red.copyWithSaturation(50); // Make less vivid + * const complementaryColor = red.copyWithHue(180); // Opposite on color wheel + */ +exports.HslColor = class HslColor extends BaseColor { + /** + * Cached transparent color instance for performance optimization. + * @static + * @private + * @type {HslColor|null} + */ + static #transparent = null; + + /** + * Cached white color instance for performance optimization. + * @static + * @private + * @type {HslColor|null} + */ + static #white = null; + + /** + * Cached black color instance for performance optimization. + * @static + * @private + * @type {HslColor|null} + */ + static #black = null; + + /** + * Returns a fully transparent HSL color (alpha = 0). + * + * @static + * @returns {HslColor} Transparent HSL color instance (0, 0%, 0%, 0) + */ + static get transparent() { + return this.#transparent || (this.#transparent = new HslColor(0, 0, 0, 0)); + } + + /** + * Returns a white HSL color with full opacity. + * + * @static + * @returns {HslColor} White HSL color instance (0, 0%, 100%, 1) + */ + static get white() { + return this.#white || (this.#white = new HslColor(0, 0, 100, 1)); + } + + /** + * Returns a black HSL color with full opacity. + * + * @static + * @returns {HslColor} Black HSL color instance (0, 0%, 0%, 1) + */ + static get black() { + return this.#black || (this.#black = new HslColor(0, 0, 0, 1)); + } + + /** + * Internal storage for the hue component value. + * @private + * @type {number} + */ + #hue = 0; + + /** + * Sets the hue component value with validation. + * @param {number} value - Hue value in degrees (0-360, where 0/360=red, 120=green, 240=blue) + * @throws {Error} If hue is not a number or not between 0 and 360 + */ + set hue(value) { + if (typeof value !== "number" || !BaseColor.isValidHue(value)) { + throw new Error("Hue value must be between 0 and 360"); + } + this.#hue = value; + } + + /** + * Gets the hue component value in degrees (0-360). + * @type {number} + * @readonly + */ + get hue() { + return this.#hue; + } + + /** + * Internal storage for the saturation component value. + * @private + * @type {number} + */ + #saturation = 0; + + /** + * Sets the saturation component value with validation. + * @param {number} value - Saturation value as percentage (0-100, where 0=grayscale, 100=pure color) + * @throws {Error} If saturation is not a number or not between 0 and 100 + */ + set saturation(value) { + if (typeof value !== "number" || !BaseColor.isValidSaturation(value)) { + throw new Error("Saturation value must be between 0 and 100"); + } + this.#saturation = value; + } + + /** + * Gets the saturation component value as percentage (0-100). + * @type {number} + * @readonly + */ + get saturation() { + return this.#saturation; + } + + /** + * Internal storage for the lightness component value. + * @private + * @type {number} + */ + #lightness = 0; + + /** + * Sets the lightness component value with validation. + * @param {number} value - Lightness value as percentage (0-100, where 0=black, 50=normal, 100=white) + * @throws {Error} If lightness is not a number or not between 0 and 100 + */ + set lightness(value) { + if (typeof value !== "number" || !BaseColor.isValidLightness(value)) { + throw new Error("Lightness value must be between 0 and 100"); + } + this.#lightness = value; + } + + /** + * Gets the lightness component value as percentage (0-100). + * @type {number} + * @readonly + */ + get lightness() { + return this.#lightness; + } + + /** + * Creates a new HslColor instance with the specified HSL and alpha values. + * All HSL values are validated to ensure they fall within their respective valid ranges. + * Alpha value is validated to ensure it falls within the valid range (0-1). + * + * @param {number} [hue=0] - Hue in degrees (0-360, where 0/360=red, 60=yellow, 120=green, 180=cyan, 240=blue, 300=magenta) + * @param {number} [saturation=0] - Saturation percentage (0-100, where 0=grayscale, 100=vivid color) + * @param {number} [lightness=0] - Lightness percentage (0-100, where 0=black, 50=normal, 100=white) + * @param {number} [alpha=1] - Alpha component (0-1, where 0=transparent, 1=opaque) + * @throws {Error} If any parameter is not a number or outside its valid range + */ + constructor(hue = 0, saturation = 0, lightness = 0, alpha = 1) { + super(alpha); + this.hue = hue; + this.saturation = saturation; + this.lightness = lightness; + } + + /** + * Creates an HslColor instance from various input formats. + * Supports HSL strings, RGB Color instances, HSL Color instances, and arrays. + * + * @static + * @param {string|HslColor|number[]} value - The value to convert to an HslColor + * @returns {HslColor} A new HslColor instance + * @throws {Error} If the input format is not supported or invalid + */ + static from(value) { + if (typeof value === "string") return this.fromString(value); + if (value instanceof HslColor) return value; + + if (Array.isArray(value) && value.length >= 3) { + const [h, s, l, a = 1] = value; + return new HslColor(h, s, l, a); + } + + throw new Error("Invalid HSL color format. Expected string, HslColor, or number array"); + } + + /** + * Creates an HslColor instance from RGB values. + * + * @static + * @param {number} red - Red component (0-255) + * @param {number} green - Green component (0-255) + * @param {number} blue - Blue component (0-255) + * @param {number} [alpha=1] - Alpha component (0-1, where 0 is transparent and 1 is opaque) + * @returns {HslColor} A new HslColor instance + * @throws {Error} If RGB values are out of range (0-255) or alpha is out of range (0-1) + * @see {@link https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB} RGB to HSL conversion algorithm + */ + static fromRgb(red, green, blue, alpha = 1) { + const hslColor = this.fromRgbToHsl(red, green, blue); + const { hue, saturation, lightness } = hslColor; + return new HslColor(hue, saturation, lightness, alpha); + } + + /** + * Creates an HslColor instance from a string representation. + * Supports HSL, HSLA formats, RGB, RGBA formats, hexadecimal strings, and CSS keywords. + * + * @static + * @param {string} string - The color string to parse + * @returns {HslColor} A new HslColor instance + * @throws {Error} If the string format is not supported or invalid + */ + static fromString(string) { + if (typeof string !== "string") { + throw new Error("Color string must be a string"); + } + + const normalized = string.trim().toLowerCase(); + + // Try transparent keyword first + if (this.isValidTransparentKeyword(normalized)) return this.transparent; + + // Try HSL/HSLA format (preferred for HSL class) + if (normalized.startsWith("hsl")) return this.fromHslString(normalized); + + // Try hex format (converted via RGB) + if (this.isValidHexColorString(normalized)) return this.fromHexString(normalized); + // Try RGB/RGBA format (converted via RGB) + if (normalized.startsWith("rgb")) return this.fromRgbString(normalized); + + // If none of the formats match, throw an error + throw new Error(`Unsupported color format: ${string}`); + } + + /** + * Creates an HslColor instance from an RGB string representation. + * Parses CSS-style rgb() and rgba() function notation and converts to HSL color space. + * + * @static + * @param {string} rgbaString - RGB/RGBA string (e.g., "rgb(255, 0, 0)" or "rgba(255, 0, 0, 0.5)") + * @returns {HslColor} A new HslColor instance + * @throws {Error} If the RGB string format is invalid or values are out of range + */ + static fromRgbString(rgbaString) { + const { red, green, blue, alpha } = super.fromRgbString(rgbaString); + return this.fromRgb(red, green, blue, alpha); + } + + /** + * Creates an HslColor instance from an HSL or HSLA string. + * Parses CSS-style hsl() and hsla() function notation directly without conversion. + * Percentage notation is required for saturation and lightness components. + * + * @static + * @param {string} hslString - HSL/HSLA string (e.g., "hsl(120, 100%, 50%)" or "hsla(120, 100%, 50%, 0.5)") + * @returns {HslColor} A new HslColor instance + * @throws {Error} If the string format is invalid or values are out of range + */ + static fromHslString(hslString) { + const { hue, saturation, lightness, alpha } = super.fromHslString(hslString); + return new HslColor(hue, saturation, lightness, alpha); + } + + /** + * Creates an HslColor instance from a hexadecimal string. + * Supports all standard hex formats with or without '#' prefix. + * + * @static + * @param {string} hexString - Hex color string with optional '#' prefix + * @returns {HslColor} A new HslColor instance + * @throws {Error} If the hex string format is invalid + */ + static fromHexString(hexString) { + const { red, green, blue, alpha } = super.fromHexString(hexString); + return this.fromRgb(red, green, blue, alpha); + } + + /** + * Calculates the relative luminance of the color according to WCAG 2.0. + * Luminance is a measure of the brightness of a color as perceived by the human eye, + * normalized to 0 for darkest black and 1 for lightest white. + * + * This calculation is essential for determining color contrast ratios for accessibility compliance. + * The formula uses gamma-corrected RGB values and specific coefficients that correspond + * to human visual sensitivity to different colors. + * + * @returns {number} Luminance value between 0 (darkest black) and 1 (brightest white) + * @see {@link https://www.w3.org/TR/WCAG20/#relativeluminancedef} WCAG 2.0 Relative Luminance Definition + * @see {@link https://www.w3.org/TR/WCAG20/#contrast-ratiodef} WCAG 2.0 Contrast Ratio Definition + */ + getLuminance() { + const { red, green, blue } = this.#toRgbColor(); + return this._getLuminance(red, green, blue); + } + + /** + * Creates a copy of the current color with a new alpha value. + * Returns a new HslColor instance with only the alpha channel modified. + * The HSL components remain unchanged. + * + * @param {number} alpha - New alpha value (0-1, where 0=transparent, 1=opaque) + * @returns {HslColor} New HslColor instance with updated alpha + * @throws {Error} If alpha value is not between 0 and 1 + */ + copyWithAlpha(alpha) { + return new HslColor(this.hue, this.saturation, this.lightness, alpha); + } + + /** + * Creates a copy of the current color with full opacity (alpha = 1). + * Returns a new HslColor instance with the alpha channel set to fully opaque. + * This is useful for removing transparency from a color while preserving its HSL values. + * + * @returns {HslColor} New HslColor instance with alpha set to 1 + */ + copyWithoutAlpha() { + return new HslColor(this.hue, this.saturation, this.lightness, 1); + } + + /** + * Creates a copy of the current color with a new saturation value. + * Returns a new HslColor instance with only the saturation component modified. + * The hue, lightness, and alpha components remain unchanged. + * + * This is particularly useful for creating muted or vivid variations of a color. + * + * @param {number} saturation - New saturation value (0-100, where 0=grayscale, 100=vivid) + * @returns {HslColor} New HslColor instance with updated saturation + * @throws {Error} If saturation value is not between 0 and 100 + */ + copyWithSaturation(saturation) { + return new HslColor(this.hue, saturation, this.lightness, this.alpha); + } + + /** + * Creates a copy of the current color with a new lightness value. + * Returns a new HslColor instance with only the lightness component modified. + * The hue, saturation, and alpha components remain unchanged. + * + * This is particularly useful for creating lighter or darker variations of a color. + * + * @param {number} lightness - New lightness value (0-100, where 0=black, 50=normal, 100=white) + * @returns {HslColor} New HslColor instance with updated lightness + * @throws {Error} If lightness value is not between 0 and 100 + */ + copyWithLightness(lightness) { + return new HslColor(this.hue, this.saturation, lightness, this.alpha); + } + + /** + * Creates a copy of the current color with a new hue value. + * Returns a new HslColor instance with only the hue component modified. + * The saturation, lightness, and alpha components remain unchanged. + * + * This is particularly useful for creating complementary colors or color rotations. + * + * @param {number} hue - New hue value (0-360, where 0/360=red, 60=yellow, 120=green, 180=cyan, 240=blue, 300=magenta) + * @returns {HslColor} New HslColor instance with updated hue + * @throws {Error} If hue value is not between 0 and 360 + */ + copyWithHue(hue) { + return new HslColor(hue, this.saturation, this.lightness, this.alpha); + } + + /** + * Creates a copy of the current color with selectively updated values. + * Returns a new HslColor instance with any combination of HSLA values modified. + * Invalid values are ignored and the original values are preserved for safety. + * This method provides a convenient way to modify multiple color components at once. + * + * @param {Object} [values={}] - Object containing values to update + * @param {number} [values.hue] - New hue value (0-360) + * @param {number} [values.saturation] - New saturation value (0-100) + * @param {number} [values.lightness] - New lightness value (0-100) + * @param {number} [values.alpha] - New alpha value (0-1) + * @returns {HslColor} New HslColor instance with updated values + */ + copyWithValues(values = {}) { + const { hue, saturation, lightness, alpha } = values; + + return new HslColor( + HslColor.isValidHue(hue) ? hue : this.hue, + HslColor.isValidSaturation(saturation) ? saturation : this.saturation, + HslColor.isValidLightness(lightness) ? lightness : this.lightness, + HslColor.isValidAlphaValue(alpha) ? alpha : this.alpha + ); + } + + /** + * Converts the HSL color to an HSLA string representation. + * Uses CSS hsla() function syntax with comma-separated values. + * Always includes the alpha component, even when fully opaque. + * + * @returns {string} HSLA color string (e.g., "hsla(120, 100%, 50%, 1)") + */ + toHslaString() { + return this._toHslaString(this.hue, this.saturation, this.lightness); + } + + /** + * Converts the HSL color to an HSL string representation (without alpha). + * Uses CSS hsl() function syntax with comma-separated values. + * The alpha component is omitted, regardless of its value. + * + * @returns {string} HSL color string (e.g., "hsl(120, 100%, 50%)") + */ + toHslString() { + return this._toHslString(this.hue, this.saturation, this.lightness); + } + + /** + * Converts the HSL color to an RGB string representation. + * First converts HSL to RGB color space, then formats as CSS rgb() function. + * The alpha component is omitted, regardless of its value. + * + * @returns {string} RGB color string (e.g., "rgb(0, 255, 0)") + */ + toRgbString() { + const { red, green, blue } = this.#toRgbColor(); + return this._toRgbString(red, green, blue); + } + + /** + * Converts the HSL color to an RGBA string representation. + * First converts HSL to RGB color space, then formats as CSS rgba() function. + * Always includes the alpha component, even when fully opaque. + * + * @returns {string} RGBA color string (e.g., "rgba(0, 255, 0, 1)") + */ + toRgbaString() { + const { red, green, blue } = this.#toRgbColor(); + return this._toRgbaString(red, green, blue); + } + + /** + * Converts the HSL color to a short hexadecimal string representation. + * Returns 3-digit hex (RGB) or 4-digit hex (RGBA) when all digit pairs are identical. + * Returns null if the color cannot be represented in short format. + * First converts HSL to RGB, then delegates to the RGB hex formatting logic. + * + * @returns {string|null} Short hex string if possible (e.g., "#f00", "#f00f"), null if not compressible + */ + toShortHexString() { + const { red, green, blue } = this.#toRgbColor(); + return this._toShortHexString(red, green, blue); + } + + /** + * Converts the HSL color to a long hexadecimal string representation. + * Always uses 6-digit hex (RRGGBB) for opaque colors or 8-digit hex (RRGGBBAA) for transparent colors. + * First converts HSL to RGB, then delegates to the RGB hex formatting logic. + * RGB values are rounded to the nearest integer before conversion. + * + * @returns {string} Long hex string (e.g., "#ff0000" or "#ff0000ff") + */ + toLongHexString() { + const { red, green, blue } = this.#toRgbColor(); + return this._toLongHexString(red, green, blue); + } + + /** + * Returns the string representation of the HSL color. + * Automatically chooses between HSL and HSLA format based on alpha value. + * For opaque colors (alpha = 1), uses HSL format for brevity. + * For transparent colors (alpha < 1), uses HSLA format to preserve alpha information. + * + * @returns {string} HSL or HSLA color string representation + */ + toString() { + if (this.isOpaque()) return this.toHslString(); + return this.toHslaString(); + } + + /** + * Converts the current HSL color to RGB color space. + * This is a private helper method used internally for RGB-based operations + * like luminance calculation and hex string generation. + * + * @private + * @returns {{red: number, green: number, blue: number}} RGB color components (0-255) + */ + #toRgbColor() { + return HslColor.fromHslToRgb(this.hue, this.saturation, this.lightness); + } +}; diff --git a/core/visual/color/rgb-color.js b/core/visual/color/rgb-color.js new file mode 100644 index 000000000..a06ae3dae --- /dev/null +++ b/core/visual/color/rgb-color.js @@ -0,0 +1,505 @@ +const BaseColor = require("core/visual/color/base-color").BaseColor; + +/** + * Represents a color in RGBA (Red, Green, Blue, Alpha) format. + * Extends the BaseColor class with RGB-specific functionality and conversion methods. + * + * RGB is the most common color representation used in digital displays and web development. + * Each component represents the intensity of red, green, and blue light: + * - Red: Intensity of red light (0-255) + * - Green: Intensity of green light (0-255) + * - Blue: Intensity of blue light (0-255) + * - Alpha: Transparency level (0-1, where 0 is transparent and 1 is opaque) + * + * @class RgbColor + * @extends BaseColor + * @see {@link https://en.wikipedia.org/wiki/RGB_color_model} RGB Color Model Documentation + * @see {@link https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/rgb} MDN RGB Color Reference + * @example + * // Create a red color + * const red = new RgbColor(255, 0, 0, 1); + * + * // Create from hex string + * const blue = RgbColor.fromString("#0000FF"); + * + * // Create from RGB string + * const green = RgbColor.fromString("rgb(0, 255, 0)"); + * + * // Create semi-transparent colors + * const semiTransparentRed = new RgbColor(255, 0, 0, 0.5); + * + * // Color manipulation + * const darkerRed = red.copyWithRed(128); + * const transparentRed = red.copyWithAlpha(0.3); + */ +exports.RgbColor = class RgbColor extends BaseColor { + /** + * Cached transparent color instance for performance optimization. + * @static + * @private + * @type {RgbColor|null} + */ + static #transparent = null; + + /** + * Cached white color instance for performance optimization. + * @static + * @private + * @type {RgbColor|null} + */ + static #white = null; + + /** + * Cached black color instance for performance optimization. + * @static + * @private + * @type {RgbColor|null} + */ + static #black = null; + + /** + * Returns a fully transparent RGB color (black with alpha = 0). + * + * @static + * @returns {RgbColor} Transparent RGB color instance (0, 0, 0, 0) + */ + static get transparent() { + return this.#transparent || (this.#transparent = new RgbColor(0, 0, 0, 0)); + } + + /** + * Returns a white RGB color with full opacity. + * + * @static + * @returns {RgbColor} White RGB color instance (255, 255, 255, 1) + */ + static get white() { + return this.#white || (this.#white = new RgbColor(255, 255, 255, 1)); + } + + /** + * Returns a black RGB color with full opacity. + * + * @static + * @returns {RgbColor} Black RGB color instance (0, 0, 0, 1) + */ + static get black() { + return this.#black || (this.#black = new RgbColor(0, 0, 0, 1)); + } + + /** + * Internal storage for the red component value. + * @private + * @type {number} + */ + #red = 0; + + /** + * Sets the red component value with validation. + * @param {number} value - Red component value (0-255, where 0=no red, 255=maximum red) + * @throws {Error} If red is not a number or not between 0 and 255 + */ + set red(value) { + if (typeof value !== "number" || !RgbColor.isValidRgbValue(value)) { + throw new Error("Red value must be between 0 and 255"); + } + this.#red = value; + } + + /** + * Gets the red component value (0-255). + * @type {number} + * @readonly + */ + get red() { + return this.#red; + } + + /** + * Internal storage for the green component value. + * @private + * @type {number} + */ + #green = 0; + + /** + * Sets the green component value with validation. + * @param {number} value - Green component value (0-255, where 0=no green, 255=maximum green) + * @throws {Error} If green is not a number or not between 0 and 255 + */ + set green(value) { + if (typeof value !== "number" || !RgbColor.isValidRgbValue(value)) { + throw new Error("Green value must be between 0 and 255"); + } + this.#green = value; + } + + /** + * Gets the green component value (0-255). + * @type {number} + * @readonly + */ + get green() { + return this.#green; + } + + /** + * Internal storage for the blue component value. + * @private + * @type {number} + */ + #blue = 0; + + /** + * Sets the blue component value with validation. + * @param {number} value - Blue component value (0-255, where 0=no blue, 255=maximum blue) + * @throws {Error} If blue is not a number or not between 0 and 255 + */ + set blue(value) { + if (typeof value !== "number" || !RgbColor.isValidRgbValue(value)) { + throw new Error("Blue value must be between 0 and 255"); + } + this.#blue = value; + } + + /** + * Gets the blue component value (0-255). + * @type {number} + * @readonly + */ + get blue() { + return this.#blue; + } + + /** + * Creates a new RgbColor instance with the specified RGBA values. + * All RGB values are validated to ensure they fall within the valid range (0-255). + * Alpha value is validated to ensure it falls within the valid range (0-1). + * + * @param {number} [red=0] - Red component (0-255) + * @param {number} [green=0] - Green component (0-255) + * @param {number} [blue=0] - Blue component (0-255) + * @param {number} [alpha=1] - Alpha component (0-1, where 0=transparent, 1=opaque) + * @throws {Error} If any parameter is not a number or outside its valid range + */ + constructor(red = 0, green = 0, blue = 0, alpha = 1) { + super(alpha); + this.red = red; + this.green = green; + this.blue = blue; + } + + /** + * Creates an RgbColor instance from various input formats. + * Supports strings, HSL colors, RGB colors, and arrays. + * + * @static + * @param {string|RgbColor|number[]} value - The value to convert to an RgbColor + * @returns {RgbColor} A new RgbColor instance + * @throws {Error} If the input format is not supported or invalid + */ + static from(value) { + if (typeof value === "string") return this.fromString(value); + if (value instanceof RgbColor) return value; + + if (Array.isArray(value) && value.length >= 3) { + const [r, g, b, a = 1] = value; + return new RgbColor(r, g, b, a); + } + + throw new Error("Invalid color format. Expected string, RgbColor, or number array"); + } + + /** + * Creates an RgbColor instance from HSL/HSLA values. + * + * @static + * @param {number} hue - Hue component (0-360 degrees) + * @param {number} saturation - Saturation component (0-100%) + * @param {number} lightness - Lightness component (0-100%) + * @param {number} [alpha=1] - Alpha component (0-1) + * @returns {RgbColor} A new RgbColor instance + * @throws {Error} If any HSL value is outside its valid range + * @see {@link https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB} HSL to RGB conversion algorithm + */ + static fromHsl(hue, saturation, lightness, alpha = 1) { + const hslColor = this.fromHslToRgb(hue, saturation, lightness); + const { red, green, blue } = hslColor; + return new RgbColor(red, green, blue, alpha); + } + + /** + * Creates an RgbColor instance from a string representation. + * Supports multiple color formats: hex, RGB, RGBA, HSL, HSLA, and CSS keywords. + * + * @static + * @param {string} string - The color string to parse + * @returns {RgbColor} A new RgbColor instance + * @throws {Error} If the string format is not supported or invalid + */ + static fromString(string) { + if (typeof string !== "string") { + throw new Error("Color string must be a string"); + } + + const normalized = string.trim().toLowerCase(); + + // Try transparent keyword first + if (this.isValidTransparentKeyword(normalized)) return this.transparent; + + // Try HSL/HSLA format + if (normalized.startsWith("hsl")) return this.fromHslString(normalized); + + // Try hex format + if (this.isValidHexColorString(normalized)) return this.fromHexString(normalized); + + // Try RGB/RGBA format + if (normalized.startsWith("rgb")) return this.fromRgbString(normalized); + + // If none of the formats match, throw an error + throw new Error(`Unsupported color format: ${string}`); + } + + /** + * Creates an RgbColor instance from an RGB or RGBA string. + * Parses CSS-style rgb() and rgba() function notation. + * + * @static + * @param {string} rgbaString - RGB/RGBA string (e.g., "rgb(255, 0, 0)" or "rgba(255, 0, 0, 0.5)") + * @returns {RgbColor} A new RgbColor instance + * @throws {Error} If the string format is invalid or values are out of range + */ + static fromRgbString(rgbaString) { + const { red, green, blue, alpha } = super.fromRgbString(rgbaString); + return new RgbColor(red, green, blue, alpha); + } + + /** + * Creates an RgbColor instance from an HSL or HSLA string. + * Parses CSS-style hsl() and hsla() function notation and converts to RGB. + * Percentage notation is required for saturation and lightness components. + * + * @static + * @param {string} hslString - HSL/HSLA string (e.g., "hsl(120, 100%, 50%)" or "hsla(120, 100%, 50%, 0.5)") + * @returns {RgbColor} A new RgbColor instance + * @throws {Error} If the string format is invalid or values are out of range + */ + static fromHslString(hslString) { + const { hue, saturation, lightness, alpha } = super.fromHslString(hslString); + return this.fromHsl(hue, saturation, lightness, alpha); + } + + /** + * Creates an RgbColor instance from a hexadecimal color string. + * Supports all standard hex formats with or without '#' prefix. + * + * @static + * @param {string} hexString - Hex color string with optional '#' prefix + * @returns {RgbColor} A new RgbColor instance + * @throws {Error} If the hex format is invalid + */ + static fromHexString(hexString) { + const { red, green, blue, alpha } = super.fromHexString(hexString); + return new RgbColor(red, green, blue, alpha); + } + + /** + * Calculates the relative luminance of the color according to WCAG 2.0. + * Luminance is a measure of the brightness of a color as perceived by the human eye, + * normalized to 0 for darkest black and 1 for lightest white. + * + * This calculation is essential for determining color contrast ratios for accessibility compliance. + * The formula uses gamma-corrected RGB values and specific coefficients that correspond + * to human visual sensitivity to different colors. + * + * @returns {number} Luminance value between 0 (darkest black) and 1 (brightest white) + * @see {@link https://www.w3.org/TR/WCAG20/#relativeluminancedef} WCAG 2.0 Relative Luminance Definition + * @see {@link https://www.w3.org/TR/WCAG20/#contrast-ratiodef} WCAG 2.0 Contrast Ratio Definition + */ + getLuminance() { + return this._getLuminance(this.red, this.green, this.blue); + } + + /** + * Creates a copy of the current color with a new alpha value. + * Returns a new RgbColor instance with only the alpha channel modified. + * The RGB components remain unchanged. + * + * @param {number} alpha - New alpha value (0-1, where 0=transparent, 1=opaque) + * @returns {RgbColor} A new RgbColor instance with the updated alpha value + * @throws {Error} If alpha value is outside the valid range (0-1) + */ + copyWithAlpha(alpha) { + return new RgbColor(this.red, this.green, this.blue, alpha); + } + + /** + * Creates a copy of the current color with full opacity (alpha = 1). + * Returns a new RgbColor instance with the alpha channel set to fully opaque. + * This is useful for removing transparency from a color while preserving its RGB values. + * + * @returns {RgbColor} A new RgbColor instance with alpha set to 1 + */ + copyWithoutAlpha() { + return new RgbColor(this.red, this.green, this.blue, 1); + } + + /** + * Creates a copy of the current color with a new red value. + * Returns a new RgbColor instance with only the red channel modified. + * The green, blue, and alpha components remain unchanged. + * + * @param {number} red - New red component value (0-255) + * @returns {RgbColor} A new RgbColor instance with the updated red value + * @throws {Error} If red value is outside the valid range (0-255) + */ + copyWithRed(red) { + return new RgbColor(red, this.green, this.blue, this.alpha); + } + + /** + * Creates a copy of the current color with a new green value. + * Returns a new RgbColor instance with only the green channel modified. + * The red, blue, and alpha components remain unchanged. + * + * @param {number} green - New green component value (0-255) + * @returns {RgbColor} A new RgbColor instance with the updated green value + * @throws {Error} If green value is outside the valid range (0-255) + */ + copyWithGreen(green) { + return new RgbColor(this.red, green, this.blue, this.alpha); + } + + /** + * Creates a copy of the current color with a new blue value. + * Returns a new RgbColor instance with only the blue channel modified. + * The red, green, and alpha components remain unchanged. + * + * @param {number} blue - New blue component value (0-255) + * @returns {RgbColor} A new RgbColor instance with the updated blue value + * @throws {Error} If blue value is outside the valid range (0-255) + */ + copyWithBlue(blue) { + return new RgbColor(this.red, this.green, blue, this.alpha); + } + + /** + * Creates a copy of the current color with multiple updated values. + * Returns a new RgbColor instance with any combination of RGBA values modified. + * Invalid values are ignored and the original values are preserved for safety. + * This method provides a convenient way to modify multiple color components at once. + * + * @param {Object} [values={}] - Object containing the values to update + * @param {number} [values.red] - New red component (0-255) + * @param {number} [values.green] - New green component (0-255) + * @param {number} [values.blue] - New blue component (0-255) + * @param {number} [values.alpha] - New alpha component (0-1) + * @returns {RgbColor} A new RgbColor instance with the updated values + */ + copyWithValues(values = {}) { + const { red, green, blue, alpha } = values; + + return new RgbColor( + RgbColor.isValidRgbValue(red) ? red : this.red, + RgbColor.isValidRgbValue(green) ? green : this.green, + RgbColor.isValidRgbValue(blue) ? blue : this.blue, + RgbColor.isValidAlphaValue(alpha) ? alpha : this.alpha + ); + } + + /** + * Converts the RGB color to an HSLA string representation. + * First converts RGB to HSL color space, then formats as CSS hsla() function. + * Always includes the alpha component, even when fully opaque. + * + * @returns {string} HSLA color string (e.g., "hsla(0, 100%, 50%, 1)") + */ + toHslaString() { + const { hue, saturation, lightness } = this.#toHslColor(); + return this._toHslaString(hue, saturation, lightness); + } + + /** + * Converts the RGB color to an HSL string representation (without alpha). + * First converts RGB to HSL color space, then formats as CSS hsl() function. + * The alpha component is omitted, regardless of its value. + * + * @returns {string} HSL color string (e.g., "hsl(0, 100%, 50%)") + */ + toHslString() { + const { hue, saturation, lightness } = this.#toHslColor(); + return this._toHslString(hue, saturation, lightness); + } + + /** + * Converts the RGB color to an RGBA string representation. + * Uses CSS rgba() function syntax with comma-separated values. + * Always includes the alpha component, even when fully opaque. + * + * @returns {string} RGBA color string (e.g., "rgba(255, 0, 0, 1)") + */ + toRgbaString() { + return this._toRgbaString(this.red, this.green, this.blue); + } + + /** + * Converts the RGB color to an RGB string representation (without alpha). + * Uses CSS rgb() function syntax with comma-separated values. + * The alpha component is omitted, regardless of its value. + * + * @returns {string} RGB color string (e.g., "rgb(255, 0, 0)") + */ + toRgbString() { + return this._toRgbString(this.red, this.green, this.blue); + } + + /** + * Converts the RGB color to a short hexadecimal format if possible. + * Returns 3-digit hex (RGB) or 4-digit hex (RGBA) when all digit pairs are identical. + * Returns null if the color cannot be represented in short format. + * + * The compression is only possible when each RGB component can be represented + * by a single hex digit repeated twice (e.g., 0x00, 0x11, 0x22, ..., 0xFF). + * + * @returns {string|null} Short hex string if possible (e.g., "#f00", "#f00f"), null if not compressible + */ + toShortHexString() { + return this._toShortHexString(this.red, this.green, this.blue); + } + + /** + * Converts the RGB color to a long hexadecimal format. + * Always uses 6-digit hex (RRGGBB) for opaque colors or 8-digit hex (RRGGBBAA) for transparent colors. + * RGB values are rounded to the nearest integer before conversion. + * + * @returns {string} Long hex string (e.g., "#ff0000" or "#ff0000ff") + */ + toLongHexString() { + return this._toLongHexString(this.red, this.green, this.blue); + } + + /** + * Returns the default string representation of the color. + * Automatically chooses between RGB and RGBA format based on opacity. + * For opaque colors (alpha = 1), uses RGB format for brevity. + * For transparent colors (alpha < 1), uses RGBA format to preserve alpha information. + * + * @returns {string} RGB or RGBA color string representation + */ + toString() { + if (this.isOpaque()) return this.toRgbString(); + return this.toRgbaString(); + } + + /** + * Converts the current RGB color to HSL color space. + * This is a private helper method used internally for HSL-based operations + * like luminance calculation and hex string generation. + * + * @private + * @returns {{hue: number, saturation: number, lightness: number}} + */ + #toHslColor() { + return RgbColor.fromRgbToHsl(this.red, this.green, this.blue); + } +}; diff --git a/test/all.js b/test/all.js index b6234201f..1c101a49d 100644 --- a/test/all.js +++ b/test/all.js @@ -143,6 +143,10 @@ module.exports = require("mod/testing/run").run(require, [ {name: "spec/meta/component-object-descriptor-spec", node: false}, {name: "spec/meta/controller-object-descriptor-spec", node: false}, {name: "spec/meta/event-descriptor-spec", node: false}, + + // Visual + {name: "spec/core/visual/color/rgb-color-spec", node: false}, + {name: "spec/core/visual/color/hsl-color-spec", node: false}, ]).then(function () { console.log('mod-testing', 'End'); }, function (err) { diff --git a/test/spec/core/visual/color/hsl-color-spec.js b/test/spec/core/visual/color/hsl-color-spec.js new file mode 100644 index 000000000..51fd4480e --- /dev/null +++ b/test/spec/core/visual/color/hsl-color-spec.js @@ -0,0 +1,610 @@ +const HslColor = require("mod/core/visual/color/hsl-color").HslColor; + +describe("mod/core/visual/color/hsl-color-spec", function () { + describe("Constructor", function () { + it("should create a color with default values", function () { + const color = new HslColor(); + expect(color.hue).toBe(0); + expect(color.saturation).toBe(0); + expect(color.lightness).toBe(0); + expect(color.alpha).toBe(1); + }); + + it("should create a color with specified HSLA values", function () { + const color = new HslColor(240, 75, 60, 0.8); + expect(color.hue).toBe(240); + expect(color.saturation).toBe(75); + expect(color.lightness).toBe(60); + expect(color.alpha).toBe(0.8); + }); + + it("should throw error for invalid hue", function () { + expect(function () { + new HslColor(-1, 50, 50); + }).toThrow(new Error("Hue value must be between 0 and 360")); + + expect(function () { + new HslColor(361, 50, 50); + }).toThrow(new Error("Hue value must be between 0 and 360")); + + expect(function () { + new HslColor("invalid", 50, 50); + }).toThrow(new Error("Hue value must be between 0 and 360")); + }); + + it("should throw error for invalid saturation", function () { + expect(function () { + new HslColor(0, -1, 50); + }).toThrow(new Error("Saturation value must be between 0 and 100")); + + expect(function () { + new HslColor(0, 101, 50); + }).toThrow(new Error("Saturation value must be between 0 and 100")); + }); + + it("should throw error for invalid lightness", function () { + expect(function () { + new HslColor(0, 50, -1); + }).toThrow(new Error("Lightness value must be between 0 and 100")); + + expect(function () { + new HslColor(0, 50, 101); + }).toThrow(new Error("Lightness value must be between 0 and 100")); + }); + }); + + describe("Static Color Constants", function () { + it("should provide transparent color", function () { + const transparent = HslColor.transparent; + expect(transparent.hue).toBe(0); + expect(transparent.saturation).toBe(0); + expect(transparent.lightness).toBe(0); + expect(transparent.alpha).toBe(0); + }); + + it("should provide white color", function () { + const white = HslColor.white; + expect(white.hue).toBe(0); + expect(white.saturation).toBe(0); + expect(white.lightness).toBe(100); + expect(white.alpha).toBe(1); + }); + + it("should provide black color", function () { + const black = HslColor.black; + expect(black.hue).toBe(0); + expect(black.saturation).toBe(0); + expect(black.lightness).toBe(0); + expect(black.alpha).toBe(1); + }); + + it("should cache color instances", function () { + const transparent1 = HslColor.transparent; + const transparent2 = HslColor.transparent; + expect(transparent1).toBe(transparent2); + + const white1 = HslColor.white; + const white2 = HslColor.white; + expect(white1).toBe(white2); + + const black1 = HslColor.black; + const black2 = HslColor.black; + expect(black1).toBe(black2); + }); + }); + + describe("Validation Methods", function () { + describe("isValidHue", function () { + it("should validate hue values", function () { + expect(HslColor.isValidHue(0)).toBe(true); + expect(HslColor.isValidHue(180)).toBe(true); + expect(HslColor.isValidHue(360)).toBe(true); + expect(HslColor.isValidHue(-1)).toBe(false); + expect(HslColor.isValidHue(361)).toBe(false); + expect(HslColor.isValidHue("invalid")).toBe(false); + }); + }); + + describe("isValidSaturation", function () { + it("should validate saturation values", function () { + expect(HslColor.isValidSaturation(0)).toBe(true); + expect(HslColor.isValidSaturation(50)).toBe(true); + expect(HslColor.isValidSaturation(100)).toBe(true); + expect(HslColor.isValidSaturation(-1)).toBe(false); + expect(HslColor.isValidSaturation(101)).toBe(false); + }); + }); + + describe("isValidLightness", function () { + it("should validate lightness values", function () { + expect(HslColor.isValidLightness(0)).toBe(true); + expect(HslColor.isValidLightness(50)).toBe(true); + expect(HslColor.isValidLightness(100)).toBe(true); + expect(HslColor.isValidLightness(-1)).toBe(false); + expect(HslColor.isValidLightness(101)).toBe(false); + }); + }); + + describe("isValidAlphaValue", function () { + it("should validate alpha values", function () { + expect(HslColor.isValidAlphaValue(0)).toBe(true); + expect(HslColor.isValidAlphaValue(0.5)).toBe(true); + expect(HslColor.isValidAlphaValue(1)).toBe(true); + expect(HslColor.isValidAlphaValue(-0.1)).toBe(false); + expect(HslColor.isValidAlphaValue(1.1)).toBe(false); + }); + }); + + describe("isValidHexColorString", function () { + it("should validate hex color strings", function () { + expect(HslColor.isValidHexColorString("#fff")).toBe(true); + expect(HslColor.isValidHexColorString("#ffffff")).toBe(true); + expect(HslColor.isValidHexColorString("#ffffffff")).toBe(true); + expect(HslColor.isValidHexColorString("fff")).toBe(true); + expect(HslColor.isValidHexColorString("ffffff")).toBe(true); + expect(HslColor.isValidHexColorString("#ff")).toBe(false); + expect(HslColor.isValidHexColorString("invalid")).toBe(false); + expect(HslColor.isValidHexColorString(123)).toBe(false); + }); + }); + + describe("isValidTransparentKeyword", function () { + it("should identify transparent color strings", function () { + expect(HslColor.isValidTransparentKeyword("transparent")).toBe(true); + expect(HslColor.isValidTransparentKeyword("initial")).toBe(true); + expect(HslColor.isValidTransparentKeyword("inherit")).toBe(true); + expect(HslColor.isValidTransparentKeyword("")).toBe(true); + expect(HslColor.isValidTransparentKeyword(" ")).toBe(true); + expect(HslColor.isValidTransparentKeyword("red")).toBe(false); + }); + }); + }); + + describe("Factory Methods", function () { + describe("from", function () { + it("should create from string", function () { + const color = HslColor.from("hsl(120, 100%, 50%)"); + expect(color.hue).toBe(120); + expect(color.saturation).toBe(100); + expect(color.lightness).toBe(50); + expect(color.alpha).toBe(1); + }); + + it("should create from array", function () { + const color = HslColor.from([240, 75, 60, 0.8]); + expect(color.hue).toBe(240); + expect(color.saturation).toBe(75); + expect(color.lightness).toBe(60); + expect(color.alpha).toBe(0.8); + }); + + it("should create from existing HslColor", function () { + const original = new HslColor(120, 100, 50); + const copy = HslColor.from(original); + expect(copy).toBe(original); + }); + + it("should throw error for invalid format", function () { + expect(function () { + HslColor.from(123); + }).toThrow(new Error("Invalid HSL color format. Expected string, HslColor, or number array")); + }); + }); + + describe("fromString", function () { + it("should create color from HSL strings", function () { + const color = HslColor.fromString("hsl(120, 100%, 50%)"); + expect(color.hue).toBe(120); + expect(color.saturation).toBe(100); + expect(color.lightness).toBe(50); + expect(color.alpha).toBe(1); + }); + + it("should create color from HSLA strings", function () { + const color = HslColor.fromString("hsla(240, 75%, 60%, 0.8)"); + expect(color.hue).toBe(240); + expect(color.saturation).toBe(75); + expect(color.lightness).toBe(60); + expect(color.alpha).toBe(0.8); + }); + + it("should create color from hex strings", function () { + const color = HslColor.fromString("#ff0000"); + expect(color.hue).toBe(0); + expect(color.saturation).toBe(100); + expect(color.lightness).toBe(50); + expect(color.alpha).toBe(1); + }); + + it("should create color from RGB strings", function () { + const color = HslColor.fromString("rgb(255, 0, 0)"); + expect(color.hue).toBe(0); + expect(color.saturation).toBe(100); + expect(color.lightness).toBe(50); + expect(color.alpha).toBe(1); + }); + + it("should create color from RGBA strings", function () { + const color = HslColor.fromString("rgba(255, 0, 0, 0.5)"); + expect(color.hue).toBe(0); + expect(color.saturation).toBe(100); + expect(color.lightness).toBe(50); + expect(color.alpha).toBe(0.5); + }); + + it("should create transparent color from transparent strings", function () { + const color = HslColor.fromString("transparent"); + expect(color.isTransparent()).toBe(true); + }); + + it("should throw error for invalid strings", function () { + expect(function () { + HslColor.fromString("invalid"); + }).toThrow(); + + expect(function () { + HslColor.fromString(123); + }).toThrow(new Error("Color string must be a string")); + }); + }); + + describe("fromHslString", function () { + it("should parse HSL strings", function () { + const color = HslColor.fromHslString("hsl(120, 100%, 50%)"); + expect(color.hue).toBe(120); + expect(color.saturation).toBe(100); + expect(color.lightness).toBe(50); + expect(color.alpha).toBe(1); + }); + + it("should parse HSLA strings", function () { + const color = HslColor.fromHslString("hsla(240, 75%, 60%, 0.8)"); + expect(color.hue).toBe(240); + expect(color.saturation).toBe(75); + expect(color.lightness).toBe(60); + expect(color.alpha).toBe(0.8); + }); + + it("should handle decimal values", function () { + const color = HslColor.fromHslString("hsla(120.5, 75.7%, 60.2%, 0.75)"); + expect(color.hue).toBe(120.5); + expect(color.saturation).toBe(75.7); + expect(color.lightness).toBe(60.2); + expect(color.alpha).toBe(0.75); + }); + + it("should throw error for invalid format", function () { + expect(function () { + HslColor.fromHslString("invalid"); + }).toThrow(); + }); + }); + + describe("fromRgbString", function () { + it("should create color from RGB string", function () { + const color = HslColor.fromRgbString("rgb(255, 0, 0)"); + expect(color.hue).toBe(0); + expect(color.saturation).toBe(100); + expect(color.lightness).toBe(50); + expect(color.alpha).toBe(1); + }); + + it("should create color from RGBA string", function () { + const color = HslColor.fromRgbString("rgba(0, 255, 0, 0.7)"); + expect(color.hue).toBe(120); + expect(color.saturation).toBe(100); + expect(color.lightness).toBe(50); + expect(color.alpha).toBe(0.7); + }); + }); + + describe("fromHexString", function () { + it("should parse 6-digit hex", function () { + const color = HslColor.fromHexString("#ff0000"); + expect(color.hue).toBe(0); + expect(color.saturation).toBe(100); + expect(color.lightness).toBe(50); + expect(color.alpha).toBe(1); + }); + + it("should parse 3-digit hex shorthand", function () { + const color = HslColor.fromHexString("#f00"); + expect(color.hue).toBe(0); + expect(color.saturation).toBe(100); + expect(color.lightness).toBe(50); + expect(color.alpha).toBe(1); + }); + + it("should parse 8-digit hex with alpha", function () { + const color = HslColor.fromHexString("#ff000080"); + expect(color.hue).toBe(0); + expect(color.saturation).toBe(100); + expect(color.lightness).toBe(50); + expect(color.alpha).toBeCloseTo(0.5, 2); + }); + + it("should work without # prefix", function () { + const color = HslColor.fromHexString("00ff00"); + expect(color.hue).toBe(120); + expect(color.saturation).toBe(100); + expect(color.lightness).toBe(50); + }); + + it("should throw error for invalid hex", function () { + expect(function () { + HslColor.fromHexString("#zz"); + }).toThrow(); + + expect(function () { + HslColor.fromHexString("#fffff"); + }).toThrow(); + }); + }); + + describe("fromRgb", function () { + it("should create color from RGB values", function () { + const color = HslColor.fromRgb(255, 0, 0); + expect(color.hue).toBe(0); + expect(color.saturation).toBe(100); + expect(color.lightness).toBe(50); + expect(color.alpha).toBe(1); + }); + + it("should create color from RGBA values", function () { + const color = HslColor.fromRgb(0, 0, 255, 0.8); + expect(color.hue).toBe(240); + expect(color.saturation).toBe(100); + expect(color.lightness).toBe(50); + expect(color.alpha).toBe(0.8); + }); + + it("should throw error for invalid RGB values", function () { + expect(function () { + HslColor.fromRgb(-1, 0, 0); + }).toThrow(); + + expect(function () { + HslColor.fromRgb(256, 0, 0); + }).toThrow(); + + expect(function () { + HslColor.fromRgb(255, 0, 0, 2); + }).toThrow(); + }); + }); + }); + + describe("Instance Methods", function () { + let color; + + beforeEach(function () { + color = new HslColor(240, 75, 60, 0.8); + }); + + describe("isTransparent", function () { + it("should detect transparent colors", function () { + expect(HslColor.transparent.isTransparent()).toBe(true); + expect(new HslColor(120, 100, 50, 0).isTransparent()).toBe(true); + expect(new HslColor(120, 100, 50, 0.5).isTransparent()).toBe(false); + }); + }); + + describe("getLuminance", function () { + it("should calculate luminance for white", function () { + expect(HslColor.white.getLuminance()).toBeCloseTo(1, 1); + }); + + it("should calculate luminance for black", function () { + expect(HslColor.black.getLuminance()).toBe(0); + }); + + it("should calculate luminance for other colors", function () { + const red = new HslColor(0, 100, 50); + expect(red.getLuminance()).toBeCloseTo(0.2126, 3); + }); + }); + + describe("copyWithAlpha", function () { + it("should create new color with different alpha", function () { + const newColor = color.copyWithAlpha(0.5); + expect(newColor.alpha).toBe(0.5); + expect(newColor.hue).toBe(color.hue); + expect(newColor.saturation).toBe(color.saturation); + expect(newColor.lightness).toBe(color.lightness); + expect(newColor).not.toBe(color); + }); + + it("should throw error for invalid alpha", function () { + expect(function () { + color.copyWithAlpha(2); + }).toThrow(); + }); + }); + + describe("copyWithoutAlpha", function () { + it("should create new color with full opacity", function () { + const newColor = color.copyWithoutAlpha(); + expect(newColor.alpha).toBe(1); + expect(newColor.hue).toBe(color.hue); + expect(newColor.saturation).toBe(color.saturation); + expect(newColor.lightness).toBe(color.lightness); + }); + }); + + describe("copyWithHue", function () { + it("should create new color with different hue value", function () { + const newColor = color.copyWithHue(120); + expect(newColor.hue).toBe(120); + expect(newColor.saturation).toBe(color.saturation); + expect(newColor.lightness).toBe(color.lightness); + expect(newColor.alpha).toBe(color.alpha); + }); + + it("should throw error for invalid hue value", function () { + expect(function () { + color.copyWithHue(361); + }).toThrow(new Error("Hue value must be between 0 and 360")); + }); + }); + + describe("copyWithSaturation", function () { + it("should create new color with different saturation value", function () { + const newColor = color.copyWithSaturation(50); + expect(newColor.saturation).toBe(50); + expect(newColor.hue).toBe(color.hue); + expect(newColor.lightness).toBe(color.lightness); + expect(newColor.alpha).toBe(color.alpha); + }); + + it("should throw error for invalid saturation value", function () { + expect(function () { + color.copyWithSaturation(101); + }).toThrow(new Error("Saturation value must be between 0 and 100")); + }); + }); + + describe("copyWithLightness", function () { + it("should create new color with different lightness value", function () { + const newColor = color.copyWithLightness(80); + expect(newColor.lightness).toBe(80); + expect(newColor.hue).toBe(color.hue); + expect(newColor.saturation).toBe(color.saturation); + expect(newColor.alpha).toBe(color.alpha); + }); + + it("should throw error for invalid lightness value", function () { + expect(function () { + color.copyWithLightness(101); + }).toThrow(new Error("Lightness value must be between 0 and 100")); + }); + }); + + describe("copyWithValues", function () { + it("should update multiple values", function () { + const newColor = color.copyWithValues({ hue: 120, alpha: 0.5 }); + expect(newColor.hue).toBe(120); + expect(newColor.alpha).toBe(0.5); + expect(newColor.saturation).toBe(color.saturation); + expect(newColor.lightness).toBe(color.lightness); + }); + + it("should ignore invalid values", function () { + const newColor = color.copyWithValues({ hue: -50, saturation: 150 }); + expect(newColor.hue).toBe(color.hue); + expect(newColor.saturation).toBe(color.saturation); + }); + + it("should work with empty object", function () { + const newColor = color.copyWithValues({}); + expect(newColor.hue).toBe(color.hue); + expect(newColor.saturation).toBe(color.saturation); + expect(newColor.lightness).toBe(color.lightness); + expect(newColor.alpha).toBe(color.alpha); + }); + }); + }); + + describe("Conversion Methods", function () { + let color; + + beforeEach(function () { + color = new HslColor(240, 75, 60, 0.8); + }); + + describe("toHslString", function () { + it("should convert to HSL string", function () { + expect(color.toHslString()).toBe("hsl(240, 75%, 60%)"); + }); + }); + + describe("toHslaString", function () { + it("should convert to HSLA string", function () { + expect(color.toHslaString()).toBe("hsla(240, 75%, 60%, 0.8)"); + }); + }); + + describe("toRgbString", function () { + it("should convert to RGB string", function () { + const red = new HslColor(0, 100, 50); + expect(red.toRgbString()).toBe("rgb(255, 0, 0)"); + }); + }); + + describe("toRgbaString", function () { + it("should convert to RGBA string", function () { + const red = new HslColor(0, 100, 50, 0.5); + expect(red.toRgbaString()).toBe("rgba(255, 0, 0, 0.5)"); + }); + }); + + describe("toString", function () { + it("should return HSLA string by default", function () { + expect(color.toString()).toBe("hsla(240, 75%, 60%, 0.8)"); + }); + + it("should return HSL string for opaque colors", function () { + const opaqueColor = new HslColor(240, 75, 60); + expect(opaqueColor.toString()).toBe("hsl(240, 75%, 60%)"); + }); + }); + + describe("toShortHexString", function () { + it("should convert to short hex string when possible", function () { + const shortColor = new HslColor(0, 100, 50); // Pure red -> #f00 + expect(shortColor.toShortHexString()).toBe("#f00"); + }); + + it("should return null when not compressible to short format", function () { + // This HSL will likely produce RGB values that can't be compressed + const nonCompressibleColor = new HslColor(240, 75, 60); + expect(nonCompressibleColor.toShortHexString()).toBeNull(); + }); + }); + + describe("toLongHexString", function () { + it("should convert to long hex string for opaque colors", function () { + const red = new HslColor(0, 100, 50); + expect(red.toLongHexString()).toBe("#ff0000"); + }); + + it("should convert to long hex string with alpha for transparent colors", function () { + const red = new HslColor(0, 100, 50, 0.5); + expect(red.toLongHexString()).toBe("#ff000080"); + }); + }); + }); + + describe("Integration Tests", function () { + it("should round-trip through HSL string conversion", function () { + const original = new HslColor(240, 75, 60, 0.8); + const hslaString = original.toHslaString(); + const converted = HslColor.fromString(hslaString); + + expect(converted.hue).toBe(original.hue); + expect(converted.saturation).toBe(original.saturation); + expect(converted.lightness).toBe(original.lightness); + expect(converted.alpha).toBe(original.alpha); + }); + + it("should round-trip through RGB conversion", function () { + const original = new HslColor(120, 100, 50); + const rgbString = original.toRgbString(); + const converted = HslColor.fromString(rgbString); + + // Allow for small rounding differences in conversion + expect(converted.hue).toBeCloseTo(original.hue, 0); + expect(converted.saturation).toBeCloseTo(original.saturation, 0); + expect(converted.lightness).toBeCloseTo(original.lightness, 0); + }); + + it("should round-trip through hex conversion", function () { + const original = new HslColor(0, 100, 50); // Pure red + const hexString = original.toLongHexString(); + const converted = HslColor.fromString(hexString); + + expect(converted.hue).toBeCloseTo(original.hue, 0); + expect(converted.saturation).toBeCloseTo(original.saturation, 0); + expect(converted.lightness).toBeCloseTo(original.lightness, 0); + }); + }); +}); diff --git a/test/spec/core/visual/color/rgb-color-spec.js b/test/spec/core/visual/color/rgb-color-spec.js new file mode 100644 index 000000000..39d971db5 --- /dev/null +++ b/test/spec/core/visual/color/rgb-color-spec.js @@ -0,0 +1,564 @@ +const RgbColor = require("mod/core/visual/color/rgb-color").RgbColor; + +describe("mod/core/visual/color/rgb-color-spec", function () { + describe("Constructor", function () { + it("should create a color with default values", function () { + const color = new RgbColor(); + expect(color.red).toBe(0); + expect(color.green).toBe(0); + expect(color.blue).toBe(0); + expect(color.alpha).toBe(1); + }); + + it("should create a color with specified RGBA values", function () { + const color = new RgbColor(255, 128, 64, 0.5); + expect(color.red).toBe(255); + expect(color.green).toBe(128); + expect(color.blue).toBe(64); + expect(color.alpha).toBe(0.5); + }); + }); + + describe("Static Color Constants", function () { + it("should provide transparent color", function () { + const transparent = RgbColor.transparent; + expect(transparent.red).toBe(0); + expect(transparent.green).toBe(0); + expect(transparent.blue).toBe(0); + expect(transparent.alpha).toBe(0); + }); + + it("should provide white color", function () { + const white = RgbColor.white; + expect(white.red).toBe(255); + expect(white.green).toBe(255); + expect(white.blue).toBe(255); + expect(white.alpha).toBe(1); + }); + + it("should provide black color", function () { + const black = RgbColor.black; + expect(black.red).toBe(0); + expect(black.green).toBe(0); + expect(black.blue).toBe(0); + expect(black.alpha).toBe(1); + }); + + it("should cache color instances", function () { + const transparent1 = RgbColor.transparent; + const transparent2 = RgbColor.transparent; + expect(transparent1).toBe(transparent2); + }); + }); + + describe("Validation Methods", function () { + describe("isValidHue", function () { + it("should validate hue values", function () { + expect(RgbColor.isValidHue(0)).toBe(true); + expect(RgbColor.isValidHue(180)).toBe(true); + expect(RgbColor.isValidHue(360)).toBe(true); + expect(RgbColor.isValidHue(-1)).toBe(false); + expect(RgbColor.isValidHue(361)).toBe(false); + expect(RgbColor.isValidHue("invalid")).toBe(false); + }); + }); + + describe("isValidSaturation", function () { + it("should validate saturation values", function () { + expect(RgbColor.isValidSaturation(0)).toBe(true); + expect(RgbColor.isValidSaturation(50)).toBe(true); + expect(RgbColor.isValidSaturation(100)).toBe(true); + expect(RgbColor.isValidSaturation(-1)).toBe(false); + expect(RgbColor.isValidSaturation(101)).toBe(false); + }); + }); + + describe("isValidLightness", function () { + it("should validate lightness values", function () { + expect(RgbColor.isValidLightness(0)).toBe(true); + expect(RgbColor.isValidLightness(50)).toBe(true); + expect(RgbColor.isValidLightness(100)).toBe(true); + expect(RgbColor.isValidLightness(-1)).toBe(false); + expect(RgbColor.isValidLightness(101)).toBe(false); + }); + }); + + describe("isValidRgbValue", function () { + it("should validate RGB values", function () { + expect(RgbColor.isValidRgbValue(0)).toBe(true); + expect(RgbColor.isValidRgbValue(128)).toBe(true); + expect(RgbColor.isValidRgbValue(255)).toBe(true); + expect(RgbColor.isValidRgbValue(-1)).toBe(false); + expect(RgbColor.isValidRgbValue(256)).toBe(false); + }); + }); + + describe("isValidAlphaValue", function () { + it("should validate alpha values", function () { + expect(RgbColor.isValidAlphaValue(0)).toBe(true); + expect(RgbColor.isValidAlphaValue(0.5)).toBe(true); + expect(RgbColor.isValidAlphaValue(1)).toBe(true); + expect(RgbColor.isValidAlphaValue(-0.1)).toBe(false); + expect(RgbColor.isValidAlphaValue(1.1)).toBe(false); + }); + }); + + describe("isValidHexColorString", function () { + it("should validate hex color strings", function () { + expect(RgbColor.isValidHexColorString("#fff")).toBe(true); + expect(RgbColor.isValidHexColorString("#ffffff")).toBe(true); + expect(RgbColor.isValidHexColorString("#ffffffff")).toBe(true); + expect(RgbColor.isValidHexColorString("fff")).toBe(true); + expect(RgbColor.isValidHexColorString("ffffff")).toBe(true); + expect(RgbColor.isValidHexColorString("#ff")).toBe(false); + expect(RgbColor.isValidHexColorString("invalid")).toBe(false); + expect(RgbColor.isValidHexColorString(123)).toBe(false); + }); + }); + + describe("isValidTransparentKeyword", function () { + it("should identify transparent color strings", function () { + expect(RgbColor.isValidTransparentKeyword("transparent")).toBe(true); + expect(RgbColor.isValidTransparentKeyword("initial")).toBe(true); + expect(RgbColor.isValidTransparentKeyword("inherit")).toBe(true); + expect(RgbColor.isValidTransparentKeyword("")).toBe(true); + expect(RgbColor.isValidTransparentKeyword(" ")).toBe(true); + expect(RgbColor.isValidTransparentKeyword("red")).toBe(false); + }); + }); + }); + + describe("Factory Methods", function () { + describe("from", function () { + it("should create from string", function () { + const color = RgbColor.from("#ff0000"); + expect(color.red).toBe(255); + expect(color.green).toBe(0); + expect(color.blue).toBe(0); + expect(color.alpha).toBe(1); + }); + + it("should create from array", function () { + const color = RgbColor.from([255, 128, 64, 0.5]); + expect(color.red).toBe(255); + expect(color.green).toBe(128); + expect(color.blue).toBe(64); + expect(color.alpha).toBe(0.5); + }); + + it("should create from existing RgbColor", function () { + const original = new RgbColor(255, 128, 64); + const copy = RgbColor.from(original); + expect(copy).toBe(original); + }); + + it("should throw error for invalid format", function () { + expect(function () { + RgbColor.from(123); + }).toThrow(new Error("Invalid color format. Expected string, RgbColor, or number array")); + }); + }); + + describe("fromString", function () { + it("should create color from hex strings", function () { + const color = RgbColor.fromString("#ff0000"); + expect(color.red).toBe(255); + expect(color.green).toBe(0); + expect(color.blue).toBe(0); + expect(color.alpha).toBe(1); + }); + + it("should create color from RGB strings", function () { + const color = RgbColor.fromString("rgb(255, 0, 0)"); + expect(color.red).toBe(255); + expect(color.green).toBe(0); + expect(color.blue).toBe(0); + expect(color.alpha).toBe(1); + }); + + it("should create color from RGBA strings", function () { + const color = RgbColor.fromString("rgba(255, 0, 0, 0.5)"); + expect(color.red).toBe(255); + expect(color.green).toBe(0); + expect(color.blue).toBe(0); + expect(color.alpha).toBe(0.5); + }); + + it("should create transparent color from transparent strings", function () { + const color = RgbColor.fromString("transparent"); + expect(color.isTransparent()).toBe(true); + }); + + it("should throw error for invalid strings", function () { + expect(function () { + RgbColor.fromString("invalid"); + }).toThrow(); + + expect(function () { + RgbColor.fromString(123); + }).toThrow(new Error("Color string must be a string")); + }); + }); + + describe("fromRgbString", function () { + it("should parse RGB strings", function () { + const color = RgbColor.fromRgbString("rgb(255, 128, 64)"); + expect(color.red).toBe(255); + expect(color.green).toBe(128); + expect(color.blue).toBe(64); + expect(color.alpha).toBe(1); + }); + + it("should parse RGBA strings", function () { + const color = RgbColor.fromRgbString("rgba(255, 128, 64, 0.5)"); + expect(color.red).toBe(255); + expect(color.green).toBe(128); + expect(color.blue).toBe(64); + expect(color.alpha).toBe(0.5); + }); + + it("should handle decimal values", function () { + const color = RgbColor.fromRgbString("rgba(252.5, 128.7, 64.2, 0.75)"); + expect(color.red).toBe(252.5); + expect(color.green).toBe(128.7); + expect(color.blue).toBe(64.2); + expect(color.alpha).toBe(0.75); + }); + + it("should throw error for invalid format", function () { + expect(function () { + RgbColor.fromRgbString("invalid"); + }).toThrow(new Error("Invalid RGBA/RGB color format")); + }); + }); + + describe("fromHexString", function () { + it("should parse 6-digit hex", function () { + const color = RgbColor.fromHexString("#ff8040"); + expect(color.red).toBe(255); + expect(color.green).toBe(128); + expect(color.blue).toBe(64); + expect(color.alpha).toBe(1); + }); + + it("should parse 3-digit hex shorthand", function () { + const color = RgbColor.fromHexString("#f84"); + expect(color.red).toBe(255); + expect(color.green).toBe(136); + expect(color.blue).toBe(68); + expect(color.alpha).toBe(1); + }); + + it("should parse 8-digit hex with alpha", function () { + const color = RgbColor.fromHexString("#ff804080"); + expect(color.red).toBe(255); + expect(color.green).toBe(128); + expect(color.blue).toBe(64); + expect(color.alpha).toBeCloseTo(0.5, 2); + }); + + it("should work without # prefix", function () { + const color = RgbColor.fromHexString("ff8040"); + expect(color.red).toBe(255); + expect(color.green).toBe(128); + expect(color.blue).toBe(64); + }); + + it("should throw error for invalid hex", function () { + expect(function () { + RgbColor.fromHexString("#zz"); + }).toThrow(); + + expect(function () { + RgbColor.fromHexString("#fffff"); + }).toThrow(); + }); + }); + + describe("fromHsl", function () { + it("should create color from HSL values", function () { + const color = RgbColor.fromHsl(0, 100, 50); + expect(color).toBeDefined(); + expect(color.red).toBe(255); + expect(color.green).toBe(0); + expect(color.blue).toBe(0); + expect(color.alpha).toBe(1); + }); + + it("should create color from HSLA values", function () { + const color = RgbColor.fromHsl(240, 100, 50, 0.8); + expect(color).toBeDefined(); + expect(color.red).toBe(0); + expect(color.green).toBe(0); + expect(color.blue).toBe(255); + expect(color.alpha).toBe(0.8); + }); + + it("should throw error for invalid HSL values", function () { + expect(function () { + RgbColor.fromHsl(-1, 100, 50); + }).toThrow(new Error("Hue value must be between 0 and 360")); + + expect(function () { + RgbColor.fromHsl(0, 101, 50); + }).toThrow(new Error("Saturation value must be between 0 and 100")); + + expect(function () { + RgbColor.fromHsl(0, 100, 101); + }).toThrow(new Error("Lightness value must be between 0 and 100")); + + expect(function () { + RgbColor.fromHsl(0, 100, 50, 2); + }).toThrow(new Error("Alpha value must be between 0 and 1")); + }); + }); + + describe("fromHslString", function () { + it("should create color from HSL string", function () { + const color = RgbColor.fromHslString("hsl(0, 100%, 50%)"); + expect(color.red).toBe(255); + expect(color.green).toBe(0); + expect(color.blue).toBe(0); + expect(color.alpha).toBe(1); + }); + + it("should create color from HSLA string", function () { + const color = RgbColor.fromHslString("hsla(0, 100%, 50%, 0.8)"); + expect(color.red).toBe(255); + expect(color.green).toBe(0); + expect(color.blue).toBe(0); + expect(color.alpha).toBe(0.8); + }); + + it("should throw error for invalid HSL string", function () { + expect(function () { + RgbColor.fromHslString("invalid"); + }).toThrow(); + }); + }); + }); + + describe("Instance Methods", function () { + let color; + + beforeEach(function () { + color = new RgbColor(255, 128, 64, 0.8); + }); + + describe("isTransparent", function () { + it("should detect transparent colors", function () { + expect(RgbColor.transparent.isTransparent()).toBe(true); + expect(new RgbColor(255, 0, 0, 0).isTransparent()).toBe(true); + expect(new RgbColor(255, 0, 0, 0.5).isTransparent()).toBe(false); + }); + }); + + describe("getLuminance", function () { + it("should calculate luminance for white", function () { + expect(RgbColor.white.getLuminance()).toBeCloseTo(1, 1); + }); + + it("should calculate luminance for black", function () { + expect(RgbColor.black.getLuminance()).toBe(0); + }); + + it("should calculate luminance for other colors", function () { + const red = new RgbColor(255, 0, 0); + expect(red.getLuminance()).toBeCloseTo(0.2126, 3); + }); + }); + + describe("copyWithAlpha", function () { + it("should create new color with different alpha", function () { + const newColor = color.copyWithAlpha(0.5); + expect(newColor.alpha).toBe(0.5); + expect(newColor.red).toBe(color.red); + expect(newColor.green).toBe(color.green); + expect(newColor.blue).toBe(color.blue); + expect(newColor).not.toBe(color); + }); + + it("should throw error for invalid alpha", function () { + expect(function () { + color.copyWithAlpha(2); + }).toThrow(new Error("Alpha value must be between 0 and 1")); + }); + }); + + describe("copyWithoutAlpha", function () { + it("should create new color with full opacity", function () { + const newColor = color.copyWithoutAlpha(); + expect(newColor.alpha).toBe(1); + expect(newColor.red).toBe(color.red); + expect(newColor.green).toBe(color.green); + expect(newColor.blue).toBe(color.blue); + }); + }); + + describe("copyWithRed", function () { + it("should create new color with different red value", function () { + const newColor = color.copyWithRed(200); + expect(newColor.red).toBe(200); + expect(newColor.green).toBe(color.green); + expect(newColor.blue).toBe(color.blue); + expect(newColor.alpha).toBe(color.alpha); + }); + + it("should throw error for invalid red value", function () { + expect(function () { + color.copyWithRed(256); + }).toThrow(new Error("Red value must be between 0 and 255")); + }); + }); + + describe("copyWithGreen", function () { + it("should create new color with different green value", function () { + const newColor = color.copyWithGreen(200); + expect(newColor.green).toBe(200); + expect(newColor.red).toBe(color.red); + expect(newColor.blue).toBe(color.blue); + expect(newColor.alpha).toBe(color.alpha); + }); + }); + + describe("copyWithBlue", function () { + it("should create new color with different blue value", function () { + const newColor = color.copyWithBlue(200); + expect(newColor.blue).toBe(200); + expect(newColor.red).toBe(color.red); + expect(newColor.green).toBe(color.green); + expect(newColor.alpha).toBe(color.alpha); + }); + }); + + describe("copyWithValues", function () { + it("should update multiple values", function () { + const newColor = color.copyWithValues({ red: 200, alpha: 0.5 }); + expect(newColor.red).toBe(200); + expect(newColor.alpha).toBe(0.5); + expect(newColor.green).toBe(color.green); + expect(newColor.blue).toBe(color.blue); + }); + + it("should ignore invalid values", function () { + const newColor = color.copyWithValues({ red: -50, green: 300 }); + expect(newColor.red).toBe(color.red); + expect(newColor.green).toBe(color.green); + }); + + it("should work with empty object", function () { + const newColor = color.copyWithValues({}); + expect(newColor.red).toBe(color.red); + expect(newColor.green).toBe(color.green); + expect(newColor.blue).toBe(color.blue); + expect(newColor.alpha).toBe(color.alpha); + }); + }); + }); + + describe("Conversion Methods", function () { + let color; + + beforeEach(function () { + color = new RgbColor(255, 128, 64, 0.8); + }); + + describe("toRgbString", function () { + it("should convert to RGB string", function () { + expect(color.toRgbString()).toBe("rgb(255, 128, 64)"); + }); + }); + + describe("toRgbaString", function () { + it("should convert to RGBA string", function () { + expect(color.toRgbaString()).toBe("rgba(255, 128, 64, 0.8)"); + }); + }); + + describe("toHexString", function () { + it("should convert to hex string with alpha", function () { + const hexString = color.toHexString(); + expect(hexString).toBe("#ff8040cc"); + }); + }); + + describe("toString", function () { + it("should return RGBA string by default", function () { + expect(color.toString()).toBe("rgba(255, 128, 64, 0.8)"); + }); + + it("should return RGB string for opaque colors", function () { + const opaqueColor = new RgbColor(255, 128, 64); + expect(opaqueColor.toString()).toBe("rgb(255, 128, 64)"); + }); + }); + + describe("toShortHexString", function () { + it("should convert to short hex string when possible", function () { + const shortColor = new RgbColor(255, 0, 0); // #ff0000 -> #f00 + expect(shortColor.toShortHexString()).toBe("#f00"); + }); + + it("should convert to short hex string with alpha when possible", function () { + const colorWithExactAlpha = new RgbColor(255, 0, 0, 136 / 255); + expect(colorWithExactAlpha.toShortHexString()).toBe("#f008"); + }); + + it("should return null when not compressible to short format", function () { + const nonCompressibleColor = new RgbColor(255, 128, 64); + expect(nonCompressibleColor.toShortHexString()).toBeNull(); + }); + }); + + describe("toLongHexString", function () { + it("should convert to long hex string for opaque colors", function () { + const opaqueColor = new RgbColor(255, 128, 64); + expect(opaqueColor.toLongHexString()).toBe("#ff8040"); + }); + + it("should convert to long hex string with alpha for transparent colors", function () { + const transparentColor = new RgbColor(255, 128, 64, 0.5); + expect(transparentColor.toLongHexString()).toBe("#ff804080"); + }); + }); + + describe("toHslString", function () { + it("should convert to HSL string", function () { + // Using a color that will produce clean HSL values + const red = new RgbColor(255, 0, 0); + expect(red.toHslString()).toBe("hsl(0, 100%, 50%)"); + }); + }); + + describe("toHslaString", function () { + it("should convert to HSLA string", function () { + // Using a color that will produce clean HSL values + const red = new RgbColor(255, 0, 0, 0.8); + expect(red.toHslaString()).toBe("hsla(0, 100%, 50%, 0.8)"); + }); + }); + }); + + describe("Integration Tests", function () { + it("should round-trip through string conversion", function () { + const original = new RgbColor(255, 128, 64, 0.8); + const rgbaString = original.toRgbaString(); + const converted = RgbColor.fromString(rgbaString); + + expect(converted.red).toBe(original.red); + expect(converted.green).toBe(original.green); + expect(converted.blue).toBe(original.blue); + expect(converted.alpha).toBe(original.alpha); + }); + + it("should round-trip through hex conversion", function () { + const original = new RgbColor(255, 128, 64); + const hexString = original.toHexString(); + const converted = RgbColor.fromString(hexString); + + expect(converted.red).toBe(original.red); + expect(converted.green).toBe(original.green); + expect(converted.blue).toBe(original.blue); + }); + }); +});