Skip to content

Commit 37aae7f

Browse files
authored
fix: correct hsl saturation and hue (#94)
1 parent 7a21583 commit 37aae7f

3 files changed

Lines changed: 145 additions & 15 deletions

File tree

src/FastColor.ts

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ export class FastColor {
8686

8787
// HSV privates
8888
private _h?: number;
89-
private _s?: number;
89+
private _hsl_s?: number;
90+
private _hsv_s?: number;
9091
private _l?: number;
9192
private _v?: number;
9293

@@ -143,7 +144,8 @@ export class FastColor {
143144
this.b = input.b;
144145
this.a = input.a;
145146
this._h = input._h;
146-
this._s = input._s;
147+
this._hsl_s = input._hsl_s;
148+
this._hsv_s = input._hsv_s;
147149
this._l = input._l;
148150
this._v = input._v;
149151
} else if (matchFormat('rgb')) {
@@ -227,16 +229,36 @@ export class FastColor {
227229
return this._h;
228230
}
229231

232+
/**
233+
* @deprecated should use getHSVSaturation or getHSLSaturation instead
234+
*/
230235
getSaturation(): number {
231-
if (typeof this._s === 'undefined') {
236+
return this.getHSVSaturation();
237+
}
238+
239+
getHSVSaturation(): number {
240+
if (typeof this._hsv_s === 'undefined') {
241+
const delta = this.getMax() - this.getMin();
242+
if (delta === 0) {
243+
this._hsv_s = 0;
244+
} else {
245+
this._hsv_s = delta / this.getMax();
246+
}
247+
}
248+
return this._hsv_s;
249+
}
250+
251+
getHSLSaturation(): number {
252+
if (typeof this._hsl_s === 'undefined') {
232253
const delta = this.getMax() - this.getMin();
233254
if (delta === 0) {
234-
this._s = 0;
255+
this._hsl_s = 0;
235256
} else {
236-
this._s = delta / this.getMax();
257+
const l = this.getLightness();
258+
this._hsl_s = (delta/255) / (1 - Math.abs(2 * l - 1));
237259
}
238260
}
239-
return this._s;
261+
return this._hsl_s;
240262
}
241263

242264
getLightness(): number {
@@ -384,7 +406,7 @@ export class FastColor {
384406
toHsl(): HSL {
385407
return {
386408
h: this.getHue(),
387-
s: this.getSaturation(),
409+
s: this.getHSLSaturation(),
388410
l: this.getLightness(),
389411
a: this.a,
390412
};
@@ -393,7 +415,7 @@ export class FastColor {
393415
/** CSS support color pattern */
394416
toHslString(): string {
395417
const h = this.getHue();
396-
const s = round(this.getSaturation() * 100);
418+
const s = round(this.getHSLSaturation() * 100);
397419
const l = round(this.getLightness() * 100);
398420

399421
return this.a !== 1
@@ -405,7 +427,7 @@ export class FastColor {
405427
toHsv(): HSV {
406428
return {
407429
h: this.getHue(),
408-
s: this.getSaturation(),
430+
s: this.getHSVSaturation(),
409431
v: this.getValue(),
410432
a: this.a,
411433
};
@@ -481,9 +503,10 @@ export class FastColor {
481503
}
482504
}
483505

484-
private fromHsl({ h, s, l, a }: OptionalA<HSL>): void {
485-
this._h = h % 360;
486-
this._s = s;
506+
private fromHsl({ h: _h, s, l, a }: OptionalA<HSL>): void {
507+
const h = ((_h % 360) + 360) % 360;
508+
this._h = h;
509+
this._hsl_s = s;
487510
this._l = l;
488511
this.a = typeof a === 'number' ? a : 1;
489512

@@ -492,6 +515,7 @@ export class FastColor {
492515
this.r = rgb;
493516
this.g = rgb;
494517
this.b = rgb;
518+
return;
495519
}
496520

497521
let r = 0,
@@ -528,9 +552,10 @@ export class FastColor {
528552
this.b = round((b + lightnessModification) * 255);
529553
}
530554

531-
private fromHsv({ h, s, v, a }: OptionalA<HSV>): void {
532-
this._h = h % 360;
533-
this._s = s;
555+
private fromHsv({ h: _h, s, v, a }: OptionalA<HSV>): void {
556+
const h = ((_h % 360) + 360) % 360;
557+
this._h = h;
558+
this._hsv_s = s;
534559
this._v = v;
535560
this.a = typeof a === 'number' ? a : 1;
536561

tests/hsl.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { FastColor } from '../src';
2+
3+
describe('hsl', () => {
4+
// Unified hex<->hsl fixtures
5+
const hexHslFixtures: { hex: string; hsl: { h: number; s: number; l: number } }[] = [
6+
// Boundaries
7+
{ hex: '#000000', hsl: { h: 0, s: 0, l: 0 } },
8+
{ hex: '#ffffff', hsl: { h: 0, s: 0, l: 1 } },
9+
10+
// Primaries
11+
{ hex: '#ff0000', hsl: { h: 0, s: 1, l: 0.5 } },
12+
{ hex: '#00ff00', hsl: { h: 120, s: 1, l: 0.5 } },
13+
{ hex: '#0000ff', hsl: { h: 240, s: 1, l: 0.5 } },
14+
15+
// Secondaries
16+
{ hex: '#ffff00', hsl: { h: 60, s: 1, l: 0.5 } },
17+
{ hex: '#00ffff', hsl: { h: 180, s: 1, l: 0.5 } },
18+
{ hex: '#ff00ff', hsl: { h: 300, s: 1, l: 0.5 } },
19+
20+
// Specific samples
21+
{ hex: '#2400c2', hsl: { h: 251, s: 1, l: 0.3804 } },
22+
{ hex: '#3d5dff', hsl: { h: 230, s: 1, l: 0.6196 } },
23+
];
24+
25+
// hex -> hsl object values and roundtrip
26+
hexHslFixtures.forEach(({ hex, hsl: expected }) => {
27+
it(`hex to hsl object values: ${hex}`, () => {
28+
const hsl = new FastColor(hex).toHsl();
29+
expect(hsl.h).toBe(expected.h);
30+
expect(hsl.s).toBe(expected.s);
31+
expect(hsl.l).toBeCloseTo(expected.l, 4);
32+
expect(hsl.a).toBe(1);
33+
34+
const back = new FastColor(hsl).toHexString();
35+
expect(back).toBe(hex);
36+
});
37+
});
38+
39+
it('setHue should not change lightness', () => {
40+
const base = new FastColor('#1677ff');
41+
expect(base.getLightness()).toBeCloseTo(new FastColor('#1677ff').getLightness(), 4);
42+
43+
const turn = base.setHue(233);
44+
expect(turn.getLightness()).toBeCloseTo(base.getLightness(), 4);
45+
});
46+
47+
const hslaAlphaCases: [string, string, number][] = [
48+
['hsla(251, 100%, 38%, 0.5)', 'hsla(251,100%,38%,0.5)', 0.5],
49+
['hsla(120, 25%, 33%, 0.7)', 'hsla(120,25%,33%,0.7)', 0.7],
50+
];
51+
52+
hslaAlphaCases.forEach(([str, normalized, alpha]) => {
53+
it(`supports hsla alpha: ${str}`, () => {
54+
expect(new FastColor(str).toHslString()).toBe(normalized);
55+
expect(new FastColor(str).toRgb().a).toBe(alpha);
56+
});
57+
});
58+
59+
it('hue 0 and 360 are equivalent', () => {
60+
const c0 = new FastColor('hsl(0, 100%, 50%)');
61+
const c360 = new FastColor('hsl(360, 100%, 50%)');
62+
expect(c0.toHexString()).toBe(c360.toHexString());
63+
});
64+
65+
it('s=0 yields grayscale regardless of hue', () => {
66+
const a = new FastColor('hsl(0, 0%, 40%)');
67+
const b = new FastColor('hsl(200, 0%, 40%)');
68+
expect(a.toHexString()).toBe(b.toHexString());
69+
});
70+
71+
it('roundtrip toHslString keeps values', () => {
72+
const c = new FastColor('hsla(120, 25%, 33%, 0.7)');
73+
expect(c.toHslString()).toBe('hsla(120,25%,33%,0.7)');
74+
const parsed = new FastColor(c.toHslString());
75+
expect(parsed.toHslString()).toBe('hsla(120,25%,33%,0.7)');
76+
});
77+
78+
it('darken and lighten adjust lightness bounds', () => {
79+
const c = new FastColor('hsl(200, 50%, 50%)');
80+
const darker = c.darken(20);
81+
const lighter = c.lighten(20);
82+
expect(darker.getLightness()).toBeCloseTo(c.getLightness() - 0.2, 4);
83+
expect(lighter.getLightness()).toBeCloseTo(c.getLightness() + 0.2, 4);
84+
85+
const minCap = c.darken(100);
86+
const maxCap = c.lighten(100);
87+
expect(minCap.getLightness()).toBe(0);
88+
expect(maxCap.getLightness()).toBe(1);
89+
});
90+
91+
it('normalizes H outside range for HSL (object input)', () => {
92+
// -60 -> 300 (magenta)
93+
expect(new FastColor({ h: -60, s: 1, l: 0.5 }).toHexString()).toBe('#ff00ff');
94+
// 420 -> 60 (yellow)
95+
expect(new FastColor({ h: 420, s: 1, l: 0.5 }).toHexString()).toBe('#ffff00');
96+
});
97+
});
98+

tests/hsv.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,11 @@ describe('hsv', () => {
5555
const turn = base.setHue(233);
5656
expect(turn.getValue()).toBe(1);
5757
});
58+
59+
it('normalizes H outside range for HSV (object input)', () => {
60+
// -60 -> 300 (magenta)
61+
expect(new FastColor({ h: -60, s: 1, v: 1 }).toHexString()).toBe('#ff00ff');
62+
// 420 -> 60 (yellow)
63+
expect(new FastColor({ h: 420, s: 1, v: 1 }).toHexString()).toBe('#ffff00');
64+
});
5865
});

0 commit comments

Comments
 (0)