From acbefdd24cea980339eb110a46bc661b6829612c Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 8 Oct 2025 17:44:44 +0900 Subject: [PATCH] feat: add color manipulation utilities --- ROADMAP.md | 2 +- docs/index.d.ts | 73 ++++++++++++++ examples/color.ts | 14 +++ src/index.ts | 12 +++ src/visual/color.ts | 227 ++++++++++++++++++++++++++++++++++++++++++++ tests/color.test.ts | 54 +++++++++++ tests/index.test.ts | 16 ++++ 7 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 examples/color.ts create mode 100644 src/visual/color.ts create mode 100644 tests/color.test.ts diff --git a/ROADMAP.md b/ROADMAP.md index 33ffa9a..44fb107 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -87,7 +87,7 @@ - [x] Pagination utilities for client-side paging - [x] Advanced diff tooling (tree diff, selective patches) - **Visual & simulation tools** - - [ ] Color manipulation helpers (RGB/HSL conversion, blending) + - [x] Color manipulation helpers (RGB/HSL conversion, blending) - [ ] Force-directed graph layout - [ ] Marching squares contour extraction - [ ] Marching cubes isosurface generation diff --git a/docs/index.d.ts b/docs/index.d.ts index 8af024a..8356696 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -152,6 +152,11 @@ export const examples: { readonly easing: 'examples/visual.ts'; readonly quadraticBezier: 'examples/visual.ts'; readonly cubicBezier: 'examples/visual.ts'; + readonly hexToRgb: 'examples/color.ts'; + readonly rgbToHex: 'examples/color.ts'; + readonly rgbToHsl: 'examples/color.ts'; + readonly hslToRgb: 'examples/color.ts'; + readonly mixRgbColors: 'examples/color.ts'; }; }; @@ -2938,6 +2943,74 @@ export function cubicBezier( t: number ): Point; +/** + * RGB color representation. + * Use for: interop between CSS colors and rendering utilities. + * Import: visual/color.ts + */ +export interface RGBColor { + r: number; + g: number; + b: number; + a?: number; +} + +/** + * HSL color representation. + * Use for: manipulating saturation and lightness in color tools. + * Import: visual/color.ts + */ +export interface HSLColor { + h: number; + s: number; + l: number; + a?: number; +} + +/** + * Options for blending RGB colors. + * Use for: creating palette variations and gradients. + * Import: visual/color.ts + */ +export interface MixColorOptions { + ratio?: number; +} + +/** + * Converts a hex color string into RGB components. + * Use for: parsing palette tokens, shader inputs, CSS colours. + * Import: visual/color.ts + */ +export function hexToRgb(hex: string): RGBColor; + +/** + * Converts RGB components into a hex color string. + * Use for: serialising computed colours, theme export. + * Import: visual/color.ts + */ +export function rgbToHex(color: RGBColor): string; + +/** + * Converts an RGB color to HSL. + * Use for: adjusting saturation/lightness while preserving hue. + * Import: visual/color.ts + */ +export function rgbToHsl(color: RGBColor): HSLColor; + +/** + * Converts an HSL color back to RGB. + * Use for: creating display-ready colors after HSL manipulations. + * Import: visual/color.ts + */ +export function hslToRgb(color: HSLColor): RGBColor; + +/** + * Blends two RGB colors together. + * Use for: highlight colors, gradients, and hover states. + * Import: visual/color.ts + */ +export function mixRgbColors(a: RGBColor, b: RGBColor, options?: MixColorOptions): RGBColor; + // ============================================================================ // 🤖 STEERING BEHAVIOURS // ============================================================================ diff --git a/examples/color.ts b/examples/color.ts new file mode 100644 index 0000000..ac57b92 --- /dev/null +++ b/examples/color.ts @@ -0,0 +1,14 @@ +import { hexToRgb, mixRgbColors, rgbToHex, rgbToHsl } from '../src/index.js'; + +const brandPrimary = '#1abc9c'; +const brandAccent = '#f1c40f'; + +const primaryRgb = hexToRgb(brandPrimary); +const accentRgb = hexToRgb(brandAccent); + +console.log('Primary RGB:', primaryRgb); +console.log('Primary HSL:', rgbToHsl(primaryRgb)); + +const highlight = mixRgbColors(primaryRgb, accentRgb, { ratio: 0.35 }); +console.log('Highlight RGB:', highlight); +console.log('Highlight hex:', rgbToHex(highlight)); diff --git a/src/index.ts b/src/index.ts index 23b929c..c34456f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -151,6 +151,11 @@ export const examples = { easing: 'examples/visual.ts', quadraticBezier: 'examples/visual.ts', cubicBezier: 'examples/visual.ts', + hexToRgb: 'examples/color.ts', + rgbToHex: 'examples/color.ts', + rgbToHsl: 'examples/color.ts', + hslToRgb: 'examples/color.ts', + mixRgbColors: 'examples/color.ts', }, } as const; @@ -1027,6 +1032,13 @@ export { easing } from './visual/easing.js'; */ export { quadraticBezier, cubicBezier } from './visual/bezier.js'; +/** + * Color conversion and blending utilities. + */ +export { hexToRgb, rgbToHex, rgbToHsl, hslToRgb, mixRgbColors } from './visual/color.js'; + +export type { RGBColor, HSLColor, MixColorOptions } from './visual/color.js'; + // ============================================================================ // 🤖 AI & BEHAVIOUR // ============================================================================ diff --git a/src/visual/color.ts b/src/visual/color.ts new file mode 100644 index 0000000..898ab4b --- /dev/null +++ b/src/visual/color.ts @@ -0,0 +1,227 @@ +const HEX_PATTERN = /^#?([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + +export interface RGBColor { + r: number; + g: number; + b: number; + a?: number; +} + +export interface HSLColor { + h: number; + s: number; + l: number; + a?: number; +} + +export interface MixColorOptions { + /** + * Amount of the second color to mix in. 0 keeps the first color, 1 replaces it entirely. + * Defaults to 0.5. + */ + ratio?: number; +} + +export function hexToRgb(hex: string): RGBColor { + const match = HEX_PATTERN.exec(hex.trim()); + if (!match) { + throw new Error('Invalid hex color format.'); + } + + const value = match[1]; + const normalized = value.length <= 4 ? expandShorthand(value) : value; + + const r = parseInt(normalized.slice(0, 2), 16); + const g = parseInt(normalized.slice(2, 4), 16); + const b = parseInt(normalized.slice(4, 6), 16); + const a = normalized.length === 8 ? parseInt(normalized.slice(6, 8), 16) / 255 : undefined; + + return { r, g, b, a }; +} + +export function rgbToHex(color: RGBColor): string { + validateRgb(color); + const r = toHexComponent(color.r); + const g = toHexComponent(color.g); + const b = toHexComponent(color.b); + const a = color.a === undefined ? '' : toHexComponent(Math.round(clamp(color.a, 0, 1) * 255)); + return `#${r}${g}${b}${a}`; +} + +export function rgbToHsl(color: RGBColor): HSLColor { + validateRgb(color); + const r = color.r / 255; + const g = color.g / 255; + const b = color.b / 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const delta = max - min; + + let h = 0; + if (delta !== 0) { + if (max === r) { + h = ((g - b) / delta) % 6; + } else if (max === g) { + h = (b - r) / delta + 2; + } else { + h = (r - g) / delta + 4; + } + h *= 60; + if (h < 0) { + h += 360; + } + } + + const l = (max + min) / 2; + const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); + + return { h, s, l, a: color.a }; +} + +export function hslToRgb(color: HSLColor): RGBColor { + validateHsl(color); + const h = mod(color.h, 360) / 60; + const s = color.s; + const l = color.l; + + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs((h % 2) - 1)); + const m = l - c / 2; + + let r = 0; + let g = 0; + let b = 0; + + if (h >= 0 && h < 1) { + r = c; + g = x; + } else if (h >= 1 && h < 2) { + r = x; + g = c; + } else if (h >= 2 && h < 3) { + g = c; + b = x; + } else if (h >= 3 && h < 4) { + g = x; + b = c; + } else if (h >= 4 && h < 5) { + r = x; + b = c; + } else { + r = c; + b = x; + } + + return { + r: Math.round(clamp((r + m) * 255, 0, 255)), + g: Math.round(clamp((g + m) * 255, 0, 255)), + b: Math.round(clamp((b + m) * 255, 0, 255)), + a: color.a, + }; +} + +export function mixRgbColors(a: RGBColor, b: RGBColor, options: MixColorOptions = {}): RGBColor { + validateRgb(a); + validateRgb(b); + const ratio = clamp(options.ratio ?? 0.5, 0, 1); + const inv = 1 - ratio; + + const alphaA = a.a ?? 1; + const alphaB = b.a ?? 1; + const mixedAlpha = alphaA * inv + alphaB * ratio; + + const mixChannel = (channelA: number, channelB: number) => Math.round(channelA * inv + channelB * ratio); + + const result: RGBColor = { + r: mixChannel(a.r, b.r), + g: mixChannel(a.g, b.g), + b: mixChannel(a.b, b.b), + }; + + if (a.a !== undefined || b.a !== undefined) { + result.a = clamp(mixedAlpha, 0, 1); + } + + return result; +} + +function expandShorthand(value: string): string { + if (value.length === 3) { + return value + .split('') + .map((char) => char + char) + .join(''); + } + if (value.length === 4) { + return value + .split('') + .map((char) => char + char) + .join(''); + } + return value; +} + +function toHexComponent(value: number): string { + const clamped = clamp(Math.round(value), 0, 255); + return clamped.toString(16).padStart(2, '0'); +} + +function clamp(value: number, min: number, max: number): number { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; +} + +function validateRgb(color: RGBColor): void { + assertFinite(color.r, 'r'); + assertFinite(color.g, 'g'); + assertFinite(color.b, 'b'); + if (!Number.isInteger(color.r) || color.r < 0 || color.r > 255) { + throw new Error('r must be an integer between 0 and 255.'); + } + if (!Number.isInteger(color.g) || color.g < 0 || color.g > 255) { + throw new Error('g must be an integer between 0 and 255.'); + } + if (!Number.isInteger(color.b) || color.b < 0 || color.b > 255) { + throw new Error('b must be an integer between 0 and 255.'); + } + if (color.a !== undefined) { + assertFinite(color.a, 'a'); + if (color.a < 0 || color.a > 1) { + throw new Error('a must be between 0 and 1.'); + } + } +} + +function validateHsl(color: HSLColor): void { + assertFinite(color.h, 'h'); + assertFinite(color.s, 's'); + assertFinite(color.l, 'l'); + if (color.s < 0 || color.s > 1) { + throw new Error('s must be between 0 and 1.'); + } + if (color.l < 0 || color.l > 1) { + throw new Error('l must be between 0 and 1.'); + } + if (color.a !== undefined) { + assertFinite(color.a, 'a'); + if (color.a < 0 || color.a > 1) { + throw new Error('a must be between 0 and 1.'); + } + } +} + +function assertFinite(value: number | undefined, label: string): void { + if (value === undefined || Number.isNaN(value) || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number.`); + } +} + +function mod(value: number, modulus: number): number { + return ((value % modulus) + modulus) % modulus; +} diff --git a/tests/color.test.ts b/tests/color.test.ts new file mode 100644 index 0000000..c23c257 --- /dev/null +++ b/tests/color.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { hexToRgb, hslToRgb, mixRgbColors, rgbToHex, rgbToHsl } from '../src/index.js'; + +describe('color utilities', () => { + it('converts between hex and rgb representations', () => { + expect(hexToRgb('#ffcc00')).toEqual({ r: 255, g: 204, b: 0 }); + expect(hexToRgb('336699')).toEqual({ r: 51, g: 102, b: 153 }); + expect(hexToRgb('#0fa8')).toEqual({ r: 0, g: 255, b: 170, a: 0.5333333333333333 }); + + expect(rgbToHex({ r: 51, g: 102, b: 153 })).toBe('#336699'); + expect(rgbToHex({ r: 0, g: 255, b: 170, a: 0.5 })).toBe('#00ffaa80'); + }); + + it('round-trips RGB and HSL conversions', () => { + const rgb = { r: 26, g: 188, b: 156 }; + const hsl = rgbToHsl(rgb); + expect(hsl.h).toBeGreaterThan(160); + expect(hsl.h).toBeLessThan(175); + expect(hsl.s).toBeCloseTo(0.76, 2); + expect(hsl.l).toBeCloseTo(0.42, 2); + + const roundTrip = hslToRgb(hsl); + expect(roundTrip).toEqual(rgb); + + const withAlpha = { h: 210, s: 0.5, l: 0.4, a: 0.25 }; + expect(hslToRgb(withAlpha)).toEqual({ r: 51, g: 102, b: 153, a: 0.25 }); + expect(rgbToHsl({ r: 51, g: 102, b: 153, a: 0.25 }).a).toBeCloseTo(0.25, 5); + }); + + it('mixes RGB colors with optional alpha', () => { + const mixed = mixRgbColors({ r: 255, g: 0, b: 0 }, { r: 0, g: 0, b: 255 }, { ratio: 0.25 }); + expect(mixed).toEqual({ r: 191, g: 0, b: 64 }); + + const mixedAlpha = mixRgbColors( + { r: 255, g: 255, b: 255, a: 0.2 }, + { r: 0, g: 0, b: 0, a: 0.8 }, + { ratio: 0.75 } + ); + expect(mixedAlpha.r).toBe(64); + expect(mixedAlpha.g).toBe(64); + expect(mixedAlpha.b).toBe(64); + expect(mixedAlpha.a).toBeDefined(); + expect(mixedAlpha.a).toBeCloseTo(0.65, 5); + }); + + it('validates input ranges', () => { + expect(() => hexToRgb('#xyz')).toThrow(); + expect(() => rgbToHex({ r: 300, g: 0, b: 0 })).toThrow('r must be an integer between 0 and 255.'); + expect(() => rgbToHsl({ r: 0, g: -1, b: 0 })).toThrow('g must be an integer between 0 and 255.'); + expect(() => hslToRgb({ h: 0, s: 1.5, l: 0.5 })).toThrow('s must be between 0 and 1.'); + expect(() => mixRgbColors({ r: 0, g: 0, b: 0, a: -0.1 }, { r: 0, g: 0, b: 0 })).toThrow('a must be between 0 and 1.'); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index 0b385a4..29e7fe7 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -27,6 +27,11 @@ describe('package entry point', () => { expect(examples.data.applyJsonDiffSelective).toBe('examples/jsonDiff.ts'); expect(examples.data.diffTree).toBe('examples/treeDiff.ts'); expect(examples.data.applyTreeDiff).toBe('examples/treeDiff.ts'); + expect(examples.visual.hexToRgb).toBe('examples/color.ts'); + expect(examples.visual.rgbToHex).toBe('examples/color.ts'); + expect(examples.visual.rgbToHsl).toBe('examples/color.ts'); + expect(examples.visual.hslToRgb).toBe('examples/color.ts'); + expect(examples.visual.mixRgbColors).toBe('examples/color.ts'); expect(examples.gameplay.createDeltaTimeManager).toBe('examples/deltaTime.ts'); expect(examples.gameplay.createFixedTimestepLoop).toBe('examples/fixedTimestep.ts'); expect(examples.gameplay.createCamera2D).toBe('examples/camera2D.ts'); @@ -167,5 +172,16 @@ describe('package entry point', () => { | 'createGeneticAlgorithm' | 'computeInfluenceMap' >(); + + expectTypeOf>().toEqualTypeOf< + | 'easing' + | 'quadraticBezier' + | 'cubicBezier' + | 'hexToRgb' + | 'rgbToHex' + | 'rgbToHsl' + | 'hslToRgb' + | 'mixRgbColors' + >(); }); });