Skip to content

Commit 258a437

Browse files
committed
impr: add alpha support to colors utils
!nuf
1 parent 1da6fbd commit 258a437

File tree

2 files changed

+201
-33
lines changed

2 files changed

+201
-33
lines changed

frontend/__tests__/utils/colors.spec.ts

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { hexToRgb } from "../../src/ts/utils/colors";
2+
import { hexToRgb, blendTwoHexColors } from "../../src/ts/utils/colors";
33

44
describe("colors.ts", () => {
55
describe("hexToRgb", () => {
@@ -9,8 +9,9 @@ describe("colors.ts", () => {
99
expect(hexToRgb("#ff")).toEqual(undefined);
1010
expect(hexToRgb("ffffff")).toEqual(undefined);
1111
expect(hexToRgb("fff")).toEqual(undefined);
12+
expect(hexToRgb("#ffffffffff")).toEqual(undefined); // Too long
1213
});
13-
it("Valid hex value", () => {
14+
it("Valid hex value without alpha", () => {
1415
expect(hexToRgb("#ffffff")).toEqual({
1516
r: 255,
1617
g: 255,
@@ -67,5 +68,98 @@ describe("colors.ts", () => {
6768
b: 86,
6869
});
6970
});
71+
72+
it("Valid hex value with alpha (RGBA format)", () => {
73+
expect(hexToRgb("#ffff")).toEqual({
74+
r: 255,
75+
g: 255,
76+
b: 255,
77+
a: 1,
78+
});
79+
expect(hexToRgb("#fff0")).toEqual({
80+
r: 255,
81+
g: 255,
82+
b: 255,
83+
a: 0,
84+
});
85+
expect(hexToRgb("#f008")).toEqual({
86+
r: 255,
87+
g: 0,
88+
b: 0,
89+
a: 0.5333333333333333, // 0x88 / 255
90+
});
91+
});
92+
93+
it("Valid hex value with alpha (RRGGBBAA format)", () => {
94+
expect(hexToRgb("#ffffffff")).toEqual({
95+
r: 255,
96+
g: 255,
97+
b: 255,
98+
a: 1,
99+
});
100+
expect(hexToRgb("#ffffff00")).toEqual({
101+
r: 255,
102+
g: 255,
103+
b: 255,
104+
a: 0,
105+
});
106+
expect(hexToRgb("#ff000080")).toEqual({
107+
r: 255,
108+
g: 0,
109+
b: 0,
110+
a: 0.5019607843137255, // 0x80 / 255
111+
});
112+
expect(hexToRgb("#00000000")).toEqual({
113+
r: 0,
114+
g: 0,
115+
b: 0,
116+
a: 0,
117+
});
118+
expect(hexToRgb("#123456ff")).toEqual({
119+
r: 18,
120+
g: 52,
121+
b: 86,
122+
a: 1,
123+
});
124+
});
125+
});
126+
127+
describe("blendTwoHexColors", () => {
128+
const cases = [
129+
{
130+
color1: "#ffffff",
131+
color2: "#000000",
132+
alpha: 0.5,
133+
expected: "#808080",
134+
display: "no opacity",
135+
},
136+
{
137+
color1: "#ffffff00",
138+
color2: "#000000",
139+
alpha: 0.5,
140+
expected: "#80808080",
141+
display: "mixed opacity",
142+
},
143+
{
144+
color1: "#ffffffff",
145+
color2: "#00000000",
146+
alpha: 0.5,
147+
expected: "#80808080",
148+
display: "with opacity",
149+
},
150+
];
151+
152+
it.each(cases)(
153+
"should blend colors correctly ($display)",
154+
({ color1, color2, alpha, expected }) => {
155+
const result = blendTwoHexColors(color1, color2, alpha);
156+
expect(result).toBe(expected);
157+
}
158+
);
159+
160+
// cases.forEach(({ color1, color2, alpha, expected }) => {
161+
// const result = blendTwoHexColors(color1, color2, alpha);
162+
// expect(result).toBe(expected);
163+
// });
70164
});
71165
});

frontend/src/ts/utils/colors.ts

Lines changed: 105 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@
22
* Utility functions for color conversions and operations.
33
*/
44

5-
import { normal as normalBlend } from "color-blend";
6-
75
/**
86
* Blends two hexadecimal colors with a given opacity.
9-
* @param color1 The first hexadecimal color value.
10-
* @param color2 The second hexadecimal color value.
7+
* @param color1 The first hexadecimal color value (supports alpha channel).
8+
* @param color2 The second hexadecimal color value (supports alpha channel).
119
* @param opacity The opacity value between 0 and 1.
1210
* @returns A new hexadecimal color value representing the blend of color1 and color2.
1311
*/
@@ -20,97 +18,158 @@ export function blendTwoHexColors(
2018
const rgb2 = hexToRgb(color2);
2119

2220
if (rgb1 && rgb2) {
23-
const rgba1 = {
24-
r: rgb1.r,
25-
g: rgb1.g,
26-
b: rgb1.b,
27-
a: 1,
28-
};
29-
const rgba2 = {
30-
r: rgb2.r,
31-
g: rgb2.g,
32-
b: rgb2.b,
33-
a: opacity,
34-
};
35-
const blended = normalBlend(rgba1, rgba2);
36-
return rgbToHex(blended.r, blended.g, blended.b);
21+
// Simple alpha blending: result = color1 * (1 - opacity) + color2 * opacity
22+
const alpha1 = rgb1.a ?? 1;
23+
const alpha2 = rgb2.a ?? 1;
24+
25+
// Blend the colors
26+
const blendedR = Math.round(rgb1.r * (1 - opacity) + rgb2.r * opacity);
27+
const blendedG = Math.round(rgb1.g * (1 - opacity) + rgb2.g * opacity);
28+
const blendedB = Math.round(rgb1.b * (1 - opacity) + rgb2.b * opacity);
29+
30+
// Blend the alpha channels
31+
const blendedA = alpha1 * (1 - opacity) + alpha2 * opacity;
32+
33+
// If either color had alpha or the blended alpha is not 1, include alpha in result
34+
if (rgb1.a !== undefined || rgb2.a !== undefined || blendedA !== 1) {
35+
return rgbToHex(blendedR, blendedG, blendedB, blendedA);
36+
} else {
37+
return rgbToHex(blendedR, blendedG, blendedB);
38+
}
3739
} else {
3840
return "#ff00ffff";
3941
}
4042
}
4143

4244
/**
43-
* Converts a hexadecimal color string to an RGB object.
44-
* @param hex The hexadecimal color string (e.g., "#ff0000" or "#f00").
45-
* @returns An object with 'r', 'g', and 'b' properties representing the red, green, and blue components of the color, or undefined if the input is invalid.
45+
* Converts a hexadecimal color string to an RGB/RGBA object.
46+
* @param hex The hexadecimal color string (e.g., "#ff0000", "#f00", "#ff0000ff", or "#f00f").
47+
* @returns An object with 'r', 'g', 'b', and optionally 'a' properties representing the red, green, blue, and alpha components of the color, or undefined if the input is invalid.
4648
*/
4749
export function hexToRgb(hex: string):
4850
| {
4951
r: number;
5052
g: number;
5153
b: number;
54+
a?: number;
5255
}
5356
| undefined {
54-
if ((hex.length !== 4 && hex.length !== 7) || !hex.startsWith("#")) {
57+
if (
58+
(hex.length !== 4 &&
59+
hex.length !== 5 &&
60+
hex.length !== 7 &&
61+
hex.length !== 9) ||
62+
!hex.startsWith("#")
63+
) {
5564
return undefined;
5665
}
5766
let r: number;
5867
let g: number;
5968
let b: number;
69+
let a: number | undefined;
70+
6071
if (hex.length === 4) {
72+
// #RGB format
73+
r = Number("0x" + hex[1] + hex[1]);
74+
g = Number("0x" + hex[2] + hex[2]);
75+
b = Number("0x" + hex[3] + hex[3]);
76+
} else if (hex.length === 5) {
77+
// #RGBA format
6178
r = Number("0x" + hex[1] + hex[1]);
6279
g = Number("0x" + hex[2] + hex[2]);
6380
b = Number("0x" + hex[3] + hex[3]);
81+
a = Number("0x" + hex[4] + hex[4]) / 255;
6482
} else if (hex.length === 7) {
83+
// #RRGGBB format
6584
r = Number("0x" + hex[1] + hex[2]);
6685
g = Number("0x" + hex[3] + hex[4]);
6786
b = Number("0x" + hex[5] + hex[6]);
87+
} else if (hex.length === 9) {
88+
// #RRGGBBAA format
89+
r = Number("0x" + hex[1] + hex[2]);
90+
g = Number("0x" + hex[3] + hex[4]);
91+
b = Number("0x" + hex[5] + hex[6]);
92+
a = Number("0x" + hex[7] + hex[8]) / 255;
6893
} else {
6994
return undefined;
7095
}
7196

72-
return { r, g, b };
97+
const result: { r: number; g: number; b: number; a?: number } = { r, g, b };
98+
if (a !== undefined) {
99+
result.a = a;
100+
}
101+
return result;
73102
}
74103

75104
/**
76-
* Converts RGB values to a hexadecimal color string.
105+
* Converts RGB/RGBA values to a hexadecimal color string.
77106
* @param r The red component (0-255).
78107
* @param g The green component (0-255).
79108
* @param b The blue component (0-255).
80-
* @returns The hexadecimal color string (e.g., "#ff0000" for red).
109+
* @param a The alpha component (0-1), optional.
110+
* @returns The hexadecimal color string (e.g., "#ff0000" for red or "#ff0000ff" for red with full opacity).
81111
*/
82-
function rgbToHex(r: number, g: number, b: number): string {
83-
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
112+
function rgbToHex(r: number, g: number, b: number, a?: number): string {
113+
const hexR = Math.round(r).toString(16).padStart(2, "0");
114+
const hexG = Math.round(g).toString(16).padStart(2, "0");
115+
const hexB = Math.round(b).toString(16).padStart(2, "0");
116+
117+
if (a !== undefined) {
118+
const hexA = Math.round(a * 255)
119+
.toString(16)
120+
.padStart(2, "0");
121+
return `#${hexR}${hexG}${hexB}${hexA}`;
122+
}
123+
124+
return `#${hexR}${hexG}${hexB}`;
84125
}
85126

86127
/**
87128
* Converts a hexadecimal color string to its HSL (Hue, Saturation, Lightness) representation.
88-
* @param hex The hexadecimal color string (e.g., "#ff0000" or "#f00").
89-
* @returns An object with 'hue', 'sat', 'lgt', and 'string' properties representing the HSL values and an HSL string representation.
129+
* @param hex The hexadecimal color string (e.g., "#ff0000", "#f00", "#ff0000ff", or "#f00f").
130+
* @returns An object with 'hue', 'sat', 'lgt', 'alpha', and 'string' properties representing the HSL values and an HSL string representation.
90131
*/
91132
export function hexToHSL(hex: string): {
92133
hue: number;
93134
sat: number;
94135
lgt: number;
136+
alpha?: number;
95137
string: string;
96138
} {
97139
// Convert hex to RGB first
98140
let r: number;
99141
let g: number;
100142
let b: number;
143+
let a: number | undefined;
144+
101145
if (hex.length === 4) {
146+
// #RGB format
102147
r = ("0x" + hex[1] + hex[1]) as unknown as number;
103148
g = ("0x" + hex[2] + hex[2]) as unknown as number;
104149
b = ("0x" + hex[3] + hex[3]) as unknown as number;
150+
} else if (hex.length === 5) {
151+
// #RGBA format
152+
r = ("0x" + hex[1] + hex[1]) as unknown as number;
153+
g = ("0x" + hex[2] + hex[2]) as unknown as number;
154+
b = ("0x" + hex[3] + hex[3]) as unknown as number;
155+
a = (("0x" + hex[4] + hex[4]) as unknown as number) / 255;
105156
} else if (hex.length === 7) {
157+
// #RRGGBB format
106158
r = ("0x" + hex[1] + hex[2]) as unknown as number;
107159
g = ("0x" + hex[3] + hex[4]) as unknown as number;
108160
b = ("0x" + hex[5] + hex[6]) as unknown as number;
161+
} else if (hex.length === 9) {
162+
// #RRGGBBAA format
163+
r = ("0x" + hex[1] + hex[2]) as unknown as number;
164+
g = ("0x" + hex[3] + hex[4]) as unknown as number;
165+
b = ("0x" + hex[5] + hex[6]) as unknown as number;
166+
a = (("0x" + hex[7] + hex[8]) as unknown as number) / 255;
109167
} else {
110168
r = 0x00;
111169
g = 0x00;
112170
b = 0x00;
113171
}
172+
114173
// Then to HSL
115174
r /= 255;
116175
g /= 255;
@@ -136,12 +195,27 @@ export function hexToHSL(hex: string): {
136195
s = +(s * 100).toFixed(1);
137196
l = +(l * 100).toFixed(1);
138197

139-
return {
198+
const result: {
199+
hue: number;
200+
sat: number;
201+
lgt: number;
202+
alpha?: number;
203+
string: string;
204+
} = {
140205
hue: h,
141206
sat: s,
142207
lgt: l,
143-
string: "hsl(" + h + "," + s + "%," + l + "%)",
208+
string:
209+
a !== undefined
210+
? `hsla(${h}, ${s}%, ${l}%, ${a.toFixed(3)})`
211+
: `hsl(${h}, ${s}%, ${l}%)`,
144212
};
213+
214+
if (a !== undefined) {
215+
result.alpha = a;
216+
}
217+
218+
return result;
145219
}
146220

147221
/**

0 commit comments

Comments
 (0)