@auaust/jaune 0.0.0 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,362 @@
1
+ import { N } from "@auaust/primitive-kit";
2
+ import type { Writable } from "type-fest";
3
+ import type {
4
+ ColorChannels,
5
+ ColorValue,
6
+ MaybeNamedColor,
7
+ NamedColor,
8
+ Rgb,
9
+ } from "~";
10
+ import {
11
+ brightness,
12
+ closestNamedColor,
13
+ contrast,
14
+ distance,
15
+ fallbackColor,
16
+ grayscale,
17
+ isAliasToNamedColor,
18
+ isBright,
19
+ isColor,
20
+ isColorChannels,
21
+ isDark,
22
+ isHex,
23
+ isNamedColor,
24
+ isOpaque,
25
+ isRgb,
26
+ isTranslucent,
27
+ isTransparent,
28
+ luminance,
29
+ namedColorAliases,
30
+ parseColor,
31
+ parseHex,
32
+ parseNamedColor,
33
+ parseRgb,
34
+ toAlphaChannel,
35
+ toColorChannels,
36
+ toHex,
37
+ toRgb,
38
+ toRgbChannel,
39
+ type,
40
+ } from "~/utils";
41
+ import { cache, channels } from "~/utils/symbols";
42
+
43
+ export class Color {
44
+ protected [channels]: Required<Writable<ColorChannels>> = undefined!;
45
+ protected [cache]: Map<string | Function, any> = new Map();
46
+
47
+ constructor(value: ColorChannels) {
48
+ this[channels] = { ...toColorChannels(value) };
49
+ }
50
+
51
+ static from(value: ColorValue): Color;
52
+ static from(red: number, green: number, blue: number, alpha?: number): Color;
53
+ static from(value: ColorValue | number, ...rest: number[]): Color {
54
+ if (N.is(value)) {
55
+ return new this({ r: value, g: rest[0], b: rest[1], a: rest[2] });
56
+ }
57
+
58
+ return new this(parseColor(value ?? fallbackColor));
59
+ }
60
+
61
+ static fromChannels(channels: ColorChannels): Color {
62
+ return new this(channels);
63
+ }
64
+
65
+ static fromRgb(rgb: Rgb): Color {
66
+ return new this(parseRgb(rgb));
67
+ }
68
+
69
+ static fromHex(hex: string): Color {
70
+ return new this(parseHex(hex));
71
+ }
72
+
73
+ static fromName(name: MaybeNamedColor): Color {
74
+ return new this(parseNamedColor(name));
75
+ }
76
+
77
+ /**
78
+ * Returns the color type of the passed value, or `undefined` if it's not a color.
79
+ *
80
+ * It is slightly stricter than the logic used to parse colors.
81
+ * It would return false for an array of channels which values are not within the expected range, where `parseColor` would return a color with the invalid values clamped.
82
+ */
83
+ static type = type;
84
+
85
+ /** Returns a boolean indicating whether the passed value is a color. */
86
+ static isColor = isColor;
87
+
88
+ /** @alias Color#isColor */
89
+ static is = isColor;
90
+
91
+ /** Returns a boolean indicating whether the passed value is a HEX color string. */
92
+ static isHex = isHex;
93
+
94
+ /** Returns a boolean indicating whether the passed value is a named color. */
95
+ static isNamedColor = isNamedColor;
96
+
97
+ /** Returns a boolean indicating whether the passed value is a color channel object. */
98
+ static isColorChannels = isColorChannels;
99
+
100
+ /** Returns a boolean indicating whether the passed value is a RGB color tuple. */
101
+ static isRgb = isRgb;
102
+
103
+ /** Returns all the aliases of a named color, including the name itself. */
104
+ static namedColorAliases = namedColorAliases;
105
+
106
+ /** Returns a boolean whether two named colors are aliases, meaning they have the same HEX value. */
107
+ static isAliasToNamedColor = isAliasToNamedColor;
108
+
109
+ /**
110
+ * Returns a new `Color` instance with the same channels as the current one.
111
+ * If you want the clone to have different channels, use the `with` method.
112
+ */
113
+ clone(): Color {
114
+ return Color.fromChannels(this[channels]);
115
+ }
116
+
117
+ /** Returns a new `Color` instance with the passed channels overriding the current ones. */
118
+ with(value: Partial<Pick<ColorChannels, "r" | "g" | "b" | "a">>): Color {
119
+ return Color.fromChannels({
120
+ ...this[channels],
121
+ ...value,
122
+ isFallback: false,
123
+ isTransformed: false,
124
+ });
125
+ }
126
+
127
+ /** Returns a new `Color` instance with the specified red channel. */
128
+ withRed(value: number): Color {
129
+ return this.with({ r: value });
130
+ }
131
+
132
+ /** Returns a new `Color` instance with the specified green channel. */
133
+ withGreen(value: number): Color {
134
+ return this.with({ g: value });
135
+ }
136
+
137
+ /** Returns a new `Color` instance with the specified blue channel. */
138
+ withBlue(value: number): Color {
139
+ return this.with({ b: value });
140
+ }
141
+
142
+ /** Returns a new `Color` instance with the specified alpha channel. */
143
+ withAlpha(value: number): Color {
144
+ return this.with({ a: value });
145
+ }
146
+
147
+ private updated(): this {
148
+ this[cache].clear();
149
+ this[channels].isFallback = false; // once updated, it's no longer a fallback
150
+ this[channels].isTransformed = false;
151
+ return this;
152
+ }
153
+
154
+ set(channel: "r" | "g" | "b" | "a", value: number): this {
155
+ this[channels][channel] =
156
+ channel === "a" ? toAlphaChannel(value) : toRgbChannel(value);
157
+ return this.updated();
158
+ }
159
+
160
+ /** The red channel of the color. */
161
+ get red(): number {
162
+ return this[channels].r;
163
+ }
164
+
165
+ /** @see Color#red */
166
+ get r(): number {
167
+ return this[channels].r;
168
+ }
169
+
170
+ set red(value: number) {
171
+ this.setRed(value);
172
+ }
173
+
174
+ setRed(value: number): this {
175
+ return this.set("r", value);
176
+ }
177
+
178
+ /** The green channel of the color. */
179
+ get green(): number {
180
+ return this[channels].g;
181
+ }
182
+
183
+ /** @see Color#green */
184
+ get g(): number {
185
+ return this[channels].g;
186
+ }
187
+
188
+ set green(value: number) {
189
+ this.setGreen(value);
190
+ }
191
+
192
+ setGreen(value: number): this {
193
+ return this.set("g", value);
194
+ }
195
+
196
+ /** The blue channel of the color. */
197
+ get blue(): number {
198
+ return this[channels].b;
199
+ }
200
+
201
+ /** @see Color#blue */
202
+ get b(): number {
203
+ return this[channels].b;
204
+ }
205
+
206
+ set blue(value: number) {
207
+ this.setBlue(value);
208
+ }
209
+
210
+ setBlue(value: number): this {
211
+ return this.set("b", value);
212
+ }
213
+
214
+ /** The alpha channel of the color. */
215
+ get alpha(): number {
216
+ return this[channels].a;
217
+ }
218
+
219
+ /** @see Color#alpha */
220
+ get a(): number {
221
+ return this[channels].a;
222
+ }
223
+
224
+ set alpha(value: number) {
225
+ this.setAlpha(value);
226
+ }
227
+
228
+ setAlpha(value: number): this {
229
+ return this.set("a", value);
230
+ }
231
+
232
+ /**
233
+ * Whether the color is the fallback color, which is used when the input is invalid.
234
+ * As soon as a color channel is updated, this will always be `false`.
235
+ */
236
+ get isFallback(): boolean {
237
+ return this[channels].isFallback;
238
+ }
239
+
240
+ /**
241
+ * Whether any of the channels have been transformed by the color parser.
242
+ * As soon as a color channel is updated, this will always be `false`.
243
+ */
244
+ get isTransformed(): boolean {
245
+ return this[channels].isTransformed;
246
+ }
247
+
248
+ /** Helper to cache data until the channels are updated. */
249
+ private memoize<T>(getter: (channels: ColorChannels) => T, key?: string): T {
250
+ // If no key is passed, we use the getter function as the key
251
+ // This means no key is required when the getter is a named function, while allowing to use anonymous functions within getters as well by passing a key
252
+ const cacheKey = key ?? getter;
253
+
254
+ if (!this[cache].has(cacheKey)) {
255
+ this[cache].set(cacheKey, getter(this[channels]));
256
+ }
257
+
258
+ return this[cache].get(cacheKey);
259
+ }
260
+
261
+ /** Checks if the color is fully opaque. */
262
+ get isOpaque(): boolean {
263
+ return this.memoize(isOpaque);
264
+ }
265
+
266
+ /** Checks if the color is fully transparent. */
267
+ get isTransparent(): boolean {
268
+ return this.memoize(isTransparent);
269
+ }
270
+
271
+ /** Checks if the color is at least partially transparent. */
272
+ get isTranslucent(): boolean {
273
+ return this.memoize(isTranslucent);
274
+ }
275
+
276
+ /**
277
+ * Returns the closest named color. If aliases exist, which one is returned is not guaranteed.
278
+ * In some cases, calling `namedColorAliases()` on the result might help achieve the desired result.
279
+ */
280
+ get closestNamedColor(): NamedColor {
281
+ return this.memoize(closestNamedColor);
282
+ }
283
+
284
+ /** The relative brightness of the color. */
285
+ get brightness(): number {
286
+ return this.memoize(brightness);
287
+ }
288
+
289
+ /** Whether the color is considered bright. */
290
+ get isBright(): boolean {
291
+ return this.memoize(isBright);
292
+ }
293
+
294
+ /** Whether the color is considered dark. */
295
+ get isDark(): boolean {
296
+ return this.memoize(isDark);
297
+ }
298
+
299
+ /** Whether the color is brighter than the passed threshold or color. */
300
+ isBrighterThan(threshold: Color | number): boolean {
301
+ return (
302
+ this.brightness >
303
+ (threshold instanceof Color ? threshold.brightness : threshold)
304
+ );
305
+ }
306
+
307
+ /** Whether the color is darker than the passed threshold or color. */
308
+ isDarkerThan(threshold: Color | number): boolean {
309
+ return (
310
+ this.brightness <
311
+ (threshold instanceof Color ? threshold.brightness : threshold)
312
+ );
313
+ }
314
+
315
+ /** Returns the relative luminance of the color. */
316
+ get luminance(): number {
317
+ return this.memoize(luminance);
318
+ }
319
+
320
+ /** Returns the contrast ratio between this color and another. */
321
+ contrast(color: ColorValue): number {
322
+ return contrast(this[channels], Color.from(color)[channels]);
323
+ }
324
+
325
+ /** Returns the distance between this color and another. If `alpha` is `true`, the alpha channel is included in the calculation. */
326
+ distance(color: ColorValue, alpha = false): number {
327
+ return distance(this[channels], Color.from(color)[channels], alpha);
328
+ }
329
+
330
+ /** Returns a new color with the grayscale equivalent of the current color, preserving the alpha channel. */
331
+ toGrayscale(): Color {
332
+ return Color.fromChannels(grayscale(this[channels]));
333
+ }
334
+
335
+ toHex(): string {
336
+ return this.memoize(toHex);
337
+ }
338
+
339
+ toRgb(): Rgb {
340
+ return this.memoize(toRgb);
341
+ }
342
+
343
+ toChannels(): ColorChannels {
344
+ return this.memoize(toColorChannels); // can't return `this[channels]` directly as it's writable, and could cause unexpected behavior
345
+ }
346
+
347
+ toString(): string {
348
+ return this.toHex();
349
+ }
350
+
351
+ valueOf(): string {
352
+ return this.toString();
353
+ }
354
+
355
+ [Symbol.toPrimitive](): string {
356
+ return this.toString();
357
+ }
358
+
359
+ toJSON(): string {
360
+ return this.toString();
361
+ }
362
+ }
package/src/index.ts CHANGED
@@ -0,0 +1,10 @@
1
+ export { Color } from "~/classes/Color";
2
+ export type {
3
+ ColorChannels,
4
+ ColorType,
5
+ ColorValue,
6
+ Hex,
7
+ MaybeNamedColor,
8
+ NamedColor,
9
+ Rgb,
10
+ } from "~/types";
@@ -0,0 +1,84 @@
1
+ import type { Color } from "~/classes/Color";
2
+ import type { namedColorsMap } from "~/utils";
3
+
4
+ type ColorTypeMap = {
5
+ channels: ColorChannels;
6
+ color: Color;
7
+ hex: Hex;
8
+ named: NamedColor;
9
+ rgb: Rgb;
10
+ };
11
+
12
+ /**
13
+ * The known color types.
14
+ *
15
+ * `channels` - An object of each color channel.
16
+ * `color` - A `Color` instance.
17
+ * `hex` - A HEX color string.
18
+ * `named` - A CSS color name.
19
+ * `rgb` - A tuple representing RGB color channels.
20
+ */
21
+ export type ColorType = keyof ColorTypeMap;
22
+
23
+ /** Represents a color in any of the various supported formats. */
24
+ export type ColorValue = ColorTypeMap[ColorType];
25
+
26
+ /** A HEX color string. It must start with a hash (#) character followed by 3, 4, 6, or 8 hexadecimal digits. */
27
+ export type Hex = `#${string}` | (string & {});
28
+
29
+ /**
30
+ * A tuple representing RGB color channels.
31
+ *
32
+ * The first three elements are integers between 0 and 255 representing the red, green, and blue channels, respectively.
33
+ * The fourth element is a number between 0 and 1 representing the alpha channel.
34
+ */
35
+ export type Rgb =
36
+ | readonly [r: number, g: number, b: number, a?: number]
37
+ | readonly number[]; // The above tuple type alone would make the type too annoying to use
38
+
39
+ /**
40
+ * A CSS color name.
41
+ *
42
+ * @see https://developer.mozilla.org/en-US/docs/Web/CSS/named-color
43
+ */
44
+ export type NamedColor = keyof typeof namedColorsMap;
45
+ export type MaybeNamedColor = NamedColor | (string & {});
46
+
47
+ /**
48
+ * An object of each color channel.
49
+ *
50
+ * `r`, `g`, and `b` are integers between 0 and 255 representing the red, green, and blue channels, respectively.
51
+ * `a` is a number between 0 and 1 representing the alpha channel.
52
+ * It may contain metadata about the color and its handling by the color parser.
53
+ */
54
+ export type ColorChannels = {
55
+ /**
56
+ * The red channel.
57
+ */
58
+ readonly r: number;
59
+
60
+ /**
61
+ * The green channel.
62
+ */
63
+ readonly g: number;
64
+
65
+ /**
66
+ * The blue channel.
67
+ */
68
+ readonly b: number;
69
+
70
+ /**
71
+ * The alpha channel.
72
+ */
73
+ readonly a?: number;
74
+
75
+ /**
76
+ * Whether any of the channels have been transformed by the color parser.
77
+ */
78
+ readonly isTransformed?: boolean;
79
+
80
+ /**
81
+ * Whether the color is the fallback color, which is used when the input is invalid.
82
+ */
83
+ readonly isFallback?: boolean;
84
+ };
@@ -0,0 +1,84 @@
1
+ import { N, O } from "@auaust/primitive-kit";
2
+ import type { ColorChannels } from "~/types";
3
+
4
+ export function isRgbChannel(value: unknown): value is number {
5
+ return N.is(value) && N.isBetween(value, 0, 255);
6
+ }
7
+
8
+ export function toRgbChannel(value: number | undefined | null): number {
9
+ return N.clamp(N.round(value), 0, 255);
10
+ }
11
+
12
+ export function isAlphaChannel(value: unknown): value is number {
13
+ return N.is(value) && N.isBetween(value, 0, 1);
14
+ }
15
+
16
+ export function toAlphaChannel(value: number | undefined | null): number {
17
+ return N.is(value) ? N.clamp(value, 0, 1) : 1;
18
+ }
19
+
20
+ export function isColorChannels(value: unknown): value is ColorChannels {
21
+ return (
22
+ O.is(value, false) &&
23
+ isRgbChannel(value.r) &&
24
+ isRgbChannel(value.g) &&
25
+ isRgbChannel(value.b) &&
26
+ isAlphaChannel(value.a ?? 1)
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Formats the input into a readonly object of color channels.
32
+ *
33
+ * It clamps the values to the valid range and sets the alpha channel to 1 if not provided.
34
+ * If the passed `isTransformed` isn't already `true`, it will set it to `true` if any of the values were clamped.
35
+ * If some channels are missing, it will return the fallback color.
36
+ */
37
+ export function toColorChannels(value: ColorChannels): Required<ColorChannels>;
38
+ export function toColorChannels(
39
+ r: number,
40
+ g: number,
41
+ b: number,
42
+ a?: number | null,
43
+ isTransformed?: boolean,
44
+ isFallback?: boolean
45
+ ): Required<ColorChannels>;
46
+ export function toColorChannels(
47
+ r: number | ColorChannels,
48
+ g?: number,
49
+ b?: number,
50
+ a?: number | null,
51
+ isTransformed?: boolean,
52
+ isFallback?: boolean
53
+ ): Required<ColorChannels> {
54
+ if (O.is(r, false)) {
55
+ ({ r, g, b, a, isTransformed, isFallback } = r);
56
+ }
57
+
58
+ const finalR = toRgbChannel(r);
59
+ const finalG = toRgbChannel(g);
60
+ const finalB = toRgbChannel(b);
61
+
62
+ if (isNaN(finalR) || isNaN(finalG) || isNaN(finalB)) {
63
+ return fallbackColor;
64
+ }
65
+
66
+ const finalA = toAlphaChannel(a);
67
+
68
+ return O.freeze({
69
+ r: finalR,
70
+ g: finalG,
71
+ b: finalB,
72
+ a: finalA,
73
+ isTransformed: !!(
74
+ isTransformed ||
75
+ finalR !== r ||
76
+ finalG !== g ||
77
+ finalB !== b ||
78
+ finalA !== (a ?? 1)
79
+ ),
80
+ isFallback: !!isFallback,
81
+ });
82
+ }
83
+
84
+ export const fallbackColor = toColorChannels(0, 0, 0, 1, false, true);
@@ -0,0 +1,72 @@
1
+ import { Color } from "~/classes/Color";
2
+ import type { ColorChannels, ColorType, ColorValue } from "~/types";
3
+ import { fallbackColor, isColorChannels } from "./channels";
4
+ import { isHex, parseHex } from "./hex";
5
+ import { isNamedColor, parseNamedColor } from "./namedColors";
6
+ import { couldBeRgb, isRgb, parseRgb } from "./rgb";
7
+ import { channels } from "./symbols";
8
+
9
+ /** Returns true if the value is any format of supported color. */
10
+ export function isColor(value: unknown): value is ColorValue {
11
+ return type(value) !== undefined;
12
+ }
13
+
14
+ /** Returns the color type of the value, or undefined if it's not a color. */
15
+ export function type(value: unknown): ColorType | undefined {
16
+ if (!value) {
17
+ return undefined;
18
+ }
19
+
20
+ if (value instanceof Color) {
21
+ return "color";
22
+ }
23
+
24
+ if (isNamedColor(value)) {
25
+ return "named";
26
+ }
27
+
28
+ if (isHex(value)) {
29
+ return "hex";
30
+ }
31
+
32
+ if (isRgb(value)) {
33
+ return "rgb";
34
+ }
35
+
36
+ if (isColorChannels(value)) {
37
+ return "channels";
38
+ }
39
+
40
+ return undefined;
41
+ }
42
+
43
+ /** Tries to parse the value as a color. If it fails, returns the fallback color. */
44
+ export function parseColor(value: unknown): ColorChannels {
45
+ if (!value) {
46
+ return fallbackColor;
47
+ }
48
+
49
+ if (value instanceof Color) {
50
+ return value[channels];
51
+ }
52
+
53
+ if (isColorChannels(value)) {
54
+ return value;
55
+ }
56
+
57
+ if (isNamedColor(value)) {
58
+ return parseNamedColor(value);
59
+ }
60
+
61
+ if (isHex(value)) {
62
+ return parseHex(value);
63
+ }
64
+
65
+ if (couldBeRgb(value)) {
66
+ // It might not be a valid RGB, in which case `parseRgb` is responsible for returning the fallback color
67
+ // In case more color types are added, it might be required to only return the result of `parseRgb` if `isFallback` is false
68
+ return parseRgb(value);
69
+ }
70
+
71
+ return fallbackColor;
72
+ }
@@ -0,0 +1,15 @@
1
+ import type { ColorChannels } from "~/types";
2
+
3
+ /** Calculate the distance between two colors. It is mostly useful for comparing distances between colors, but the value itself is not very meaningful. */
4
+ export function distance(
5
+ a: ColorChannels,
6
+ b: ColorChannels,
7
+ alpha = false
8
+ ): number {
9
+ return Math.sqrt(
10
+ Math.pow(a.r - b.r, 2) +
11
+ Math.pow(a.g - b.g, 2) +
12
+ Math.pow(a.b - b.b, 2) +
13
+ (alpha ? Math.pow((a.a ?? 1) - (b.a ?? 1), 2) : 0)
14
+ );
15
+ }
@@ -0,0 +1,75 @@
1
+ import { N, S } from "@auaust/primitive-kit";
2
+ import type { ColorChannels, Hex } from "~/types";
3
+ import { toColorChannels, toRgbChannel } from "./channels";
4
+
5
+ const hexadecimalRegex = /^[0-9a-f]+$/i;
6
+
7
+ /**
8
+ * Whether the input is a valid HEX color. With or without the alpha channel, and with single or double digits.
9
+ *
10
+ * It may or may not start with a hash character, which will be ignored.
11
+ */
12
+ export function isHex(value: unknown): value is Hex {
13
+ value = S.is(value) && (value.startsWith("#") ? value.slice(1) : value);
14
+
15
+ if (!S.isStrict(value)) {
16
+ return false;
17
+ }
18
+
19
+ switch (value.length) {
20
+ case 3: // RGB
21
+ case 4: // RGBA
22
+ case 6: // RRGGBB
23
+ case 8: // RRGGBBAA
24
+ return hexadecimalRegex.test(value);
25
+ default:
26
+ return false;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Parses a hex string into an object of color channels.
32
+ *
33
+ * The input must already be a valid HEX color, otherwise the result will be unexpected.
34
+ */
35
+ export function parseHex(value: Hex): ColorChannels {
36
+ value = value.startsWith("#") ? value.slice(1) : value;
37
+
38
+ const isShort = value.length < 6;
39
+ const hasAlpha = isShort ? value.length === 4 : value.length === 8;
40
+
41
+ const r = parseInt(isShort ? value[0].repeat(2) : value.slice(0, 2), 16);
42
+ const g = parseInt(isShort ? value[1].repeat(2) : value.slice(2, 4), 16);
43
+ const b = parseInt(isShort ? value[2].repeat(2) : value.slice(4, 6), 16);
44
+ const a = hasAlpha
45
+ ? parseInt(isShort ? value[3].repeat(2) : value.slice(6, 8), 16) / 255
46
+ : 1;
47
+
48
+ return toColorChannels(r, g, b, a);
49
+ }
50
+
51
+ /**
52
+ * Returns the corresponding hex string of a color channels object.
53
+ *
54
+ * If the alpha channel is 1, it will be omitted.
55
+ */
56
+ export function toHex(channels: ColorChannels): Hex {
57
+ const { r, g, b, a } = channels;
58
+
59
+ return (
60
+ "#" +
61
+ (
62
+ (1 << 24) +
63
+ (toRgbChannel(r) << 16) +
64
+ (toRgbChannel(g) << 8) +
65
+ toRgbChannel(b)
66
+ )
67
+ .toString(16)
68
+ .substring(1) +
69
+ (N.is(a) && a < 1
70
+ ? N.round(N.max(0, a) * 255)
71
+ .toString(16)
72
+ .padStart(2, "0")
73
+ : "")
74
+ );
75
+ }