Skip to content

Commit acbefdd

Browse files
committed
feat: add color manipulation utilities
1 parent 221b15e commit acbefdd

7 files changed

Lines changed: 397 additions & 1 deletion

File tree

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
- [x] Pagination utilities for client-side paging
8888
- [x] Advanced diff tooling (tree diff, selective patches)
8989
- **Visual & simulation tools**
90-
- [ ] Color manipulation helpers (RGB/HSL conversion, blending)
90+
- [x] Color manipulation helpers (RGB/HSL conversion, blending)
9191
- [ ] Force-directed graph layout
9292
- [ ] Marching squares contour extraction
9393
- [ ] Marching cubes isosurface generation

docs/index.d.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,11 @@ export const examples: {
152152
readonly easing: 'examples/visual.ts';
153153
readonly quadraticBezier: 'examples/visual.ts';
154154
readonly cubicBezier: 'examples/visual.ts';
155+
readonly hexToRgb: 'examples/color.ts';
156+
readonly rgbToHex: 'examples/color.ts';
157+
readonly rgbToHsl: 'examples/color.ts';
158+
readonly hslToRgb: 'examples/color.ts';
159+
readonly mixRgbColors: 'examples/color.ts';
155160
};
156161
};
157162

@@ -2938,6 +2943,74 @@ export function cubicBezier(
29382943
t: number
29392944
): Point;
29402945

2946+
/**
2947+
* RGB color representation.
2948+
* Use for: interop between CSS colors and rendering utilities.
2949+
* Import: visual/color.ts
2950+
*/
2951+
export interface RGBColor {
2952+
r: number;
2953+
g: number;
2954+
b: number;
2955+
a?: number;
2956+
}
2957+
2958+
/**
2959+
* HSL color representation.
2960+
* Use for: manipulating saturation and lightness in color tools.
2961+
* Import: visual/color.ts
2962+
*/
2963+
export interface HSLColor {
2964+
h: number;
2965+
s: number;
2966+
l: number;
2967+
a?: number;
2968+
}
2969+
2970+
/**
2971+
* Options for blending RGB colors.
2972+
* Use for: creating palette variations and gradients.
2973+
* Import: visual/color.ts
2974+
*/
2975+
export interface MixColorOptions {
2976+
ratio?: number;
2977+
}
2978+
2979+
/**
2980+
* Converts a hex color string into RGB components.
2981+
* Use for: parsing palette tokens, shader inputs, CSS colours.
2982+
* Import: visual/color.ts
2983+
*/
2984+
export function hexToRgb(hex: string): RGBColor;
2985+
2986+
/**
2987+
* Converts RGB components into a hex color string.
2988+
* Use for: serialising computed colours, theme export.
2989+
* Import: visual/color.ts
2990+
*/
2991+
export function rgbToHex(color: RGBColor): string;
2992+
2993+
/**
2994+
* Converts an RGB color to HSL.
2995+
* Use for: adjusting saturation/lightness while preserving hue.
2996+
* Import: visual/color.ts
2997+
*/
2998+
export function rgbToHsl(color: RGBColor): HSLColor;
2999+
3000+
/**
3001+
* Converts an HSL color back to RGB.
3002+
* Use for: creating display-ready colors after HSL manipulations.
3003+
* Import: visual/color.ts
3004+
*/
3005+
export function hslToRgb(color: HSLColor): RGBColor;
3006+
3007+
/**
3008+
* Blends two RGB colors together.
3009+
* Use for: highlight colors, gradients, and hover states.
3010+
* Import: visual/color.ts
3011+
*/
3012+
export function mixRgbColors(a: RGBColor, b: RGBColor, options?: MixColorOptions): RGBColor;
3013+
29413014
// ============================================================================
29423015
// 🤖 STEERING BEHAVIOURS
29433016
// ============================================================================

examples/color.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { hexToRgb, mixRgbColors, rgbToHex, rgbToHsl } from '../src/index.js';
2+
3+
const brandPrimary = '#1abc9c';
4+
const brandAccent = '#f1c40f';
5+
6+
const primaryRgb = hexToRgb(brandPrimary);
7+
const accentRgb = hexToRgb(brandAccent);
8+
9+
console.log('Primary RGB:', primaryRgb);
10+
console.log('Primary HSL:', rgbToHsl(primaryRgb));
11+
12+
const highlight = mixRgbColors(primaryRgb, accentRgb, { ratio: 0.35 });
13+
console.log('Highlight RGB:', highlight);
14+
console.log('Highlight hex:', rgbToHex(highlight));

src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,11 @@ export const examples = {
151151
easing: 'examples/visual.ts',
152152
quadraticBezier: 'examples/visual.ts',
153153
cubicBezier: 'examples/visual.ts',
154+
hexToRgb: 'examples/color.ts',
155+
rgbToHex: 'examples/color.ts',
156+
rgbToHsl: 'examples/color.ts',
157+
hslToRgb: 'examples/color.ts',
158+
mixRgbColors: 'examples/color.ts',
154159
},
155160
} as const;
156161

@@ -1027,6 +1032,13 @@ export { easing } from './visual/easing.js';
10271032
*/
10281033
export { quadraticBezier, cubicBezier } from './visual/bezier.js';
10291034

1035+
/**
1036+
* Color conversion and blending utilities.
1037+
*/
1038+
export { hexToRgb, rgbToHex, rgbToHsl, hslToRgb, mixRgbColors } from './visual/color.js';
1039+
1040+
export type { RGBColor, HSLColor, MixColorOptions } from './visual/color.js';
1041+
10301042
// ============================================================================
10311043
// 🤖 AI & BEHAVIOUR
10321044
// ============================================================================

src/visual/color.ts

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
const HEX_PATTERN = /^#?([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
2+
3+
export interface RGBColor {
4+
r: number;
5+
g: number;
6+
b: number;
7+
a?: number;
8+
}
9+
10+
export interface HSLColor {
11+
h: number;
12+
s: number;
13+
l: number;
14+
a?: number;
15+
}
16+
17+
export interface MixColorOptions {
18+
/**
19+
* Amount of the second color to mix in. 0 keeps the first color, 1 replaces it entirely.
20+
* Defaults to 0.5.
21+
*/
22+
ratio?: number;
23+
}
24+
25+
export function hexToRgb(hex: string): RGBColor {
26+
const match = HEX_PATTERN.exec(hex.trim());
27+
if (!match) {
28+
throw new Error('Invalid hex color format.');
29+
}
30+
31+
const value = match[1];
32+
const normalized = value.length <= 4 ? expandShorthand(value) : value;
33+
34+
const r = parseInt(normalized.slice(0, 2), 16);
35+
const g = parseInt(normalized.slice(2, 4), 16);
36+
const b = parseInt(normalized.slice(4, 6), 16);
37+
const a = normalized.length === 8 ? parseInt(normalized.slice(6, 8), 16) / 255 : undefined;
38+
39+
return { r, g, b, a };
40+
}
41+
42+
export function rgbToHex(color: RGBColor): string {
43+
validateRgb(color);
44+
const r = toHexComponent(color.r);
45+
const g = toHexComponent(color.g);
46+
const b = toHexComponent(color.b);
47+
const a = color.a === undefined ? '' : toHexComponent(Math.round(clamp(color.a, 0, 1) * 255));
48+
return `#${r}${g}${b}${a}`;
49+
}
50+
51+
export function rgbToHsl(color: RGBColor): HSLColor {
52+
validateRgb(color);
53+
const r = color.r / 255;
54+
const g = color.g / 255;
55+
const b = color.b / 255;
56+
57+
const max = Math.max(r, g, b);
58+
const min = Math.min(r, g, b);
59+
const delta = max - min;
60+
61+
let h = 0;
62+
if (delta !== 0) {
63+
if (max === r) {
64+
h = ((g - b) / delta) % 6;
65+
} else if (max === g) {
66+
h = (b - r) / delta + 2;
67+
} else {
68+
h = (r - g) / delta + 4;
69+
}
70+
h *= 60;
71+
if (h < 0) {
72+
h += 360;
73+
}
74+
}
75+
76+
const l = (max + min) / 2;
77+
const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
78+
79+
return { h, s, l, a: color.a };
80+
}
81+
82+
export function hslToRgb(color: HSLColor): RGBColor {
83+
validateHsl(color);
84+
const h = mod(color.h, 360) / 60;
85+
const s = color.s;
86+
const l = color.l;
87+
88+
const c = (1 - Math.abs(2 * l - 1)) * s;
89+
const x = c * (1 - Math.abs((h % 2) - 1));
90+
const m = l - c / 2;
91+
92+
let r = 0;
93+
let g = 0;
94+
let b = 0;
95+
96+
if (h >= 0 && h < 1) {
97+
r = c;
98+
g = x;
99+
} else if (h >= 1 && h < 2) {
100+
r = x;
101+
g = c;
102+
} else if (h >= 2 && h < 3) {
103+
g = c;
104+
b = x;
105+
} else if (h >= 3 && h < 4) {
106+
g = x;
107+
b = c;
108+
} else if (h >= 4 && h < 5) {
109+
r = x;
110+
b = c;
111+
} else {
112+
r = c;
113+
b = x;
114+
}
115+
116+
return {
117+
r: Math.round(clamp((r + m) * 255, 0, 255)),
118+
g: Math.round(clamp((g + m) * 255, 0, 255)),
119+
b: Math.round(clamp((b + m) * 255, 0, 255)),
120+
a: color.a,
121+
};
122+
}
123+
124+
export function mixRgbColors(a: RGBColor, b: RGBColor, options: MixColorOptions = {}): RGBColor {
125+
validateRgb(a);
126+
validateRgb(b);
127+
const ratio = clamp(options.ratio ?? 0.5, 0, 1);
128+
const inv = 1 - ratio;
129+
130+
const alphaA = a.a ?? 1;
131+
const alphaB = b.a ?? 1;
132+
const mixedAlpha = alphaA * inv + alphaB * ratio;
133+
134+
const mixChannel = (channelA: number, channelB: number) => Math.round(channelA * inv + channelB * ratio);
135+
136+
const result: RGBColor = {
137+
r: mixChannel(a.r, b.r),
138+
g: mixChannel(a.g, b.g),
139+
b: mixChannel(a.b, b.b),
140+
};
141+
142+
if (a.a !== undefined || b.a !== undefined) {
143+
result.a = clamp(mixedAlpha, 0, 1);
144+
}
145+
146+
return result;
147+
}
148+
149+
function expandShorthand(value: string): string {
150+
if (value.length === 3) {
151+
return value
152+
.split('')
153+
.map((char) => char + char)
154+
.join('');
155+
}
156+
if (value.length === 4) {
157+
return value
158+
.split('')
159+
.map((char) => char + char)
160+
.join('');
161+
}
162+
return value;
163+
}
164+
165+
function toHexComponent(value: number): string {
166+
const clamped = clamp(Math.round(value), 0, 255);
167+
return clamped.toString(16).padStart(2, '0');
168+
}
169+
170+
function clamp(value: number, min: number, max: number): number {
171+
if (value < min) {
172+
return min;
173+
}
174+
if (value > max) {
175+
return max;
176+
}
177+
return value;
178+
}
179+
180+
function validateRgb(color: RGBColor): void {
181+
assertFinite(color.r, 'r');
182+
assertFinite(color.g, 'g');
183+
assertFinite(color.b, 'b');
184+
if (!Number.isInteger(color.r) || color.r < 0 || color.r > 255) {
185+
throw new Error('r must be an integer between 0 and 255.');
186+
}
187+
if (!Number.isInteger(color.g) || color.g < 0 || color.g > 255) {
188+
throw new Error('g must be an integer between 0 and 255.');
189+
}
190+
if (!Number.isInteger(color.b) || color.b < 0 || color.b > 255) {
191+
throw new Error('b must be an integer between 0 and 255.');
192+
}
193+
if (color.a !== undefined) {
194+
assertFinite(color.a, 'a');
195+
if (color.a < 0 || color.a > 1) {
196+
throw new Error('a must be between 0 and 1.');
197+
}
198+
}
199+
}
200+
201+
function validateHsl(color: HSLColor): void {
202+
assertFinite(color.h, 'h');
203+
assertFinite(color.s, 's');
204+
assertFinite(color.l, 'l');
205+
if (color.s < 0 || color.s > 1) {
206+
throw new Error('s must be between 0 and 1.');
207+
}
208+
if (color.l < 0 || color.l > 1) {
209+
throw new Error('l must be between 0 and 1.');
210+
}
211+
if (color.a !== undefined) {
212+
assertFinite(color.a, 'a');
213+
if (color.a < 0 || color.a > 1) {
214+
throw new Error('a must be between 0 and 1.');
215+
}
216+
}
217+
}
218+
219+
function assertFinite(value: number | undefined, label: string): void {
220+
if (value === undefined || Number.isNaN(value) || !Number.isFinite(value)) {
221+
throw new Error(`${label} must be a finite number.`);
222+
}
223+
}
224+
225+
function mod(value: number, modulus: number): number {
226+
return ((value % modulus) + modulus) % modulus;
227+
}

0 commit comments

Comments
 (0)