@luntta/swatch 3.0.0

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/CONTRIBUTING.md +89 -0
  3. package/LICENSE +21 -0
  4. package/MIGRATING.md +189 -0
  5. package/README.md +463 -0
  6. package/package.json +57 -0
  7. package/scripts/pack-check.mjs +18 -0
  8. package/src/bootstrap.js +159 -0
  9. package/src/core/registry.js +81 -0
  10. package/src/core/state.js +36 -0
  11. package/src/core/swatch-class.js +524 -0
  12. package/src/data/cvd-matrices.js +179 -0
  13. package/src/data/named-colors.js +157 -0
  14. package/src/format/css.js +256 -0
  15. package/src/operations/accessibility.js +103 -0
  16. package/src/operations/apca.js +80 -0
  17. package/src/operations/blend.js +72 -0
  18. package/src/operations/channels.js +123 -0
  19. package/src/operations/cvd.js +119 -0
  20. package/src/operations/deltaE.js +207 -0
  21. package/src/operations/gamut.js +206 -0
  22. package/src/operations/image.js +192 -0
  23. package/src/operations/manipulation.js +100 -0
  24. package/src/operations/mix.js +129 -0
  25. package/src/operations/naming.js +158 -0
  26. package/src/operations/palette.js +133 -0
  27. package/src/operations/random.js +75 -0
  28. package/src/operations/temperature.js +126 -0
  29. package/src/operations/tint-shade.js +42 -0
  30. package/src/palettes/colorbrewer.js +232 -0
  31. package/src/palettes/index.js +58 -0
  32. package/src/palettes/viridis.js +59 -0
  33. package/src/parse/css.js +241 -0
  34. package/src/parse/hex.js +38 -0
  35. package/src/parse/index.js +43 -0
  36. package/src/parse/legacy.js +88 -0
  37. package/src/parse/named.js +11 -0
  38. package/src/parse/objects.js +125 -0
  39. package/src/scale/index.js +382 -0
  40. package/src/scale/interpolators.js +83 -0
  41. package/src/spaces/a98.js +55 -0
  42. package/src/spaces/cmyk.js +75 -0
  43. package/src/spaces/display-p3.js +50 -0
  44. package/src/spaces/hsl.js +93 -0
  45. package/src/spaces/hsluv.js +211 -0
  46. package/src/spaces/hsv.js +78 -0
  47. package/src/spaces/hwb.js +48 -0
  48. package/src/spaces/lab.js +70 -0
  49. package/src/spaces/lch.js +65 -0
  50. package/src/spaces/oklab.js +79 -0
  51. package/src/spaces/oklch.js +53 -0
  52. package/src/spaces/prophoto.js +72 -0
  53. package/src/spaces/rec2020.js +65 -0
  54. package/src/spaces/srgb.js +85 -0
  55. package/src/spaces/xyz.js +71 -0
  56. package/src/swatch.js +57 -0
  57. package/src/util/math.js +53 -0
  58. package/src/util/matrix.js +92 -0
  59. package/src/util/suggest.js +66 -0
  60. package/types/swatch.d.ts +664 -0
@@ -0,0 +1,55 @@
1
+ // Adobe RGB (1998) / A98 color space.
2
+ //
3
+ // D65 white with a pure power-law transfer function (γ = 563/256 ≈
4
+ // 2.19921875). Historically significant in print/photo pipelines for
5
+ // its wider green reach compared to sRGB.
6
+ //
7
+ // Matrices from the CSS Color 4 sample code.
8
+
9
+ import { multiplyMatrixVector } from "../util/matrix.js";
10
+ import { registerSpace } from "../core/registry.js";
11
+
12
+ const GAMMA = 563 / 256; // 2.19921875
13
+
14
+ export function a98ToLinear(coords) {
15
+ const f = (v) => Math.sign(v) * Math.pow(Math.abs(v), GAMMA);
16
+ return [f(coords[0]), f(coords[1]), f(coords[2])];
17
+ }
18
+
19
+ export function linearToA98(lin) {
20
+ const f = (v) => Math.sign(v) * Math.pow(Math.abs(v), 1 / GAMMA);
21
+ return [f(lin[0]), f(lin[1]), f(lin[2])];
22
+ }
23
+
24
+ export const LINEAR_A98_TO_XYZ_D65 = [
25
+ [0.5766690429101305, 0.1855582379065463, 0.1882286462349947],
26
+ [0.29734497525053605, 0.6273635662554661, 0.07529145849399788],
27
+ [0.02703136138641234, 0.07068885253582723, 0.9913375368376388]
28
+ ];
29
+
30
+ export const XYZ_D65_TO_LINEAR_A98 = [
31
+ [2.0415879038107465, -0.5650069742788596, -0.34473135077832957],
32
+ [-0.9692436362808795, 1.8759675015077202, 0.04155505740717557],
33
+ [0.013444280632031142, -0.11836239223101835, 1.0151749943912054]
34
+ ];
35
+
36
+ export function linearA98ToXyz(lin) {
37
+ return multiplyMatrixVector(LINEAR_A98_TO_XYZ_D65, lin);
38
+ }
39
+
40
+ export function xyzToLinearA98(xyz) {
41
+ return multiplyMatrixVector(XYZ_D65_TO_LINEAR_A98, xyz);
42
+ }
43
+
44
+ registerSpace({
45
+ id: "a98",
46
+ channels: ["r", "g", "b"],
47
+ ranges: [
48
+ [0, 1],
49
+ [0, 1],
50
+ [0, 1]
51
+ ],
52
+ white: "D65",
53
+ toXYZ: (coords) => linearA98ToXyz(a98ToLinear(coords)),
54
+ fromXYZ: (xyz) => linearToA98(xyzToLinearA98(xyz))
55
+ });
@@ -0,0 +1,75 @@
1
+ // CMYK — naive device-independent conversion.
2
+ //
3
+ // WARNING: this is the textbook "no profile" CMYK that every color
4
+ // library ships. It has no relationship to any real printer's behavior,
5
+ // which requires an ICC profile and a specific ink set. For on-screen
6
+ // use (CMYK-as-quadruple in CSS pipelines, CMYK-ish color picker math),
7
+ // it's fine; for print production, run it through a real CMS.
8
+
9
+ import { registerSpace, getSpace } from "../core/registry.js";
10
+
11
+ export function srgbToCmyk(rgb) {
12
+ const [r, g, b] = rgb;
13
+ const k = 1 - Math.max(r, g, b);
14
+ if (k >= 1 - 1e-12) return [0, 0, 0, 1];
15
+ const c = (1 - r - k) / (1 - k);
16
+ const m = (1 - g - k) / (1 - k);
17
+ const y = (1 - b - k) / (1 - k);
18
+ return [c, m, y, k];
19
+ }
20
+
21
+ export function cmykToSrgb(cmyk) {
22
+ const [c, m, y, k] = cmyk;
23
+ return [(1 - c) * (1 - k), (1 - m) * (1 - k), (1 - y) * (1 - k)];
24
+ }
25
+
26
+ // CMYK is 4-channel, which doesn't fit the 3-channel canonical state.
27
+ // We store it in a 3-channel shell by stashing K in the conversion
28
+ // shortcut layer: the registry treats cmyk as { c, m, y } plus a
29
+ // separate `_k` stored via the second-channel pattern... wait, that
30
+ // breaks the invariant.
31
+ //
32
+ // Simpler: cmyk is stored as [c, m, y] internally with K derived on
33
+ // conversion. To preserve all four values for a user who constructs
34
+ // from {c, m, y, k}, we embed k into the space by treating the storage
35
+ // as 3D and converting deterministically on the way in/out — losing a
36
+ // degree of freedom. This matches colorjs.io's approach: CMYK is a
37
+ // "view" not a "storage" space. parse/objects.js therefore immediately
38
+ // converts {c,m,y,k} into sRGB and stores it as srgb.
39
+ //
40
+ // For registration purposes, CMYK is still a registered space so
41
+ // `c.cmyk` and `c.to('cmyk')` work — it converts sRGB on demand.
42
+ // Channels on the stored state are [c, m, y]; K is computed at read
43
+ // time by re-running srgbToCmyk on the round-tripped sRGB coords.
44
+
45
+ export function srgbToCmykTriplet(rgb) {
46
+ // Store [c, m, y] compressed without K; the sRGB round-trip
47
+ // loses the K degree of freedom which is fine since device-
48
+ // independent CMYK is a view, not a storage space.
49
+ const [c, m, y, k] = srgbToCmyk(rgb);
50
+ // Fold K into CMY for the 3-channel view:
51
+ return [c + k - c * k, m + k - m * k, y + k - y * k];
52
+ }
53
+
54
+ export function cmykTripletToSrgb(cmy) {
55
+ // Inverse of the folded-K representation.
56
+ return [1 - cmy[0], 1 - cmy[1], 1 - cmy[2]];
57
+ }
58
+
59
+ registerSpace({
60
+ id: "cmyk",
61
+ channels: ["c", "m", "y"],
62
+ ranges: [
63
+ [0, 1],
64
+ [0, 1],
65
+ [0, 1]
66
+ ],
67
+ white: "D65",
68
+ toXYZ: (coords) => getSpace("srgb").toXYZ(cmykTripletToSrgb(coords)),
69
+ fromXYZ: (xyz) => srgbToCmykTriplet(getSpace("srgb").fromXYZ(xyz)),
70
+ shortcuts: {
71
+ srgb: (coords) => cmykTripletToSrgb(coords)
72
+ }
73
+ });
74
+
75
+ getSpace("srgb").shortcuts.cmyk = (coords) => srgbToCmykTriplet(coords);
@@ -0,0 +1,50 @@
1
+ // Display P3 color space.
2
+ //
3
+ // P3 primaries + D65 white, using the same transfer function as sRGB
4
+ // (the 0.04045 knee, γ ≈ 2.4). P3 is the default wide-gamut space for
5
+ // modern displays; Apple adopted it industry-wide and CSS Color 4
6
+ // standardizes it under `color(display-p3 r g b)`.
7
+ //
8
+ // Matrices from the CSS Color 4 sample code
9
+ // (https://www.w3.org/TR/css-color-4/#color-conversion-code).
10
+
11
+ import { multiplyMatrixVector } from "../util/matrix.js";
12
+ import { registerSpace } from "../core/registry.js";
13
+ import { srgbToLinear, linearToSrgb } from "./srgb.js";
14
+
15
+ export const LINEAR_P3_TO_XYZ_D65 = [
16
+ [0.4865709486482162, 0.26566769316909306, 0.1982172852343625],
17
+ [0.2289745640697488, 0.6917385218365064, 0.079286914093745],
18
+ [0.0, 0.04511338185890264, 1.043944368900976]
19
+ ];
20
+
21
+ export const XYZ_D65_TO_LINEAR_P3 = [
22
+ [2.493496911941425, -0.9313836179191239, -0.40271078445071684],
23
+ [-0.8294889695615747, 1.7626640603183463, 0.023624685841943577],
24
+ [0.03584583024378447, -0.07617238926804182, 0.9568845240076872]
25
+ ];
26
+
27
+ export function linearP3ToXyz(lin) {
28
+ return multiplyMatrixVector(LINEAR_P3_TO_XYZ_D65, lin);
29
+ }
30
+
31
+ export function xyzToLinearP3(xyz) {
32
+ return multiplyMatrixVector(XYZ_D65_TO_LINEAR_P3, xyz);
33
+ }
34
+
35
+ // Display P3 uses the sRGB EOTF, so we reuse the sRGB helpers.
36
+ export const p3ToLinear = srgbToLinear;
37
+ export const linearToP3 = linearToSrgb;
38
+
39
+ registerSpace({
40
+ id: "display-p3",
41
+ channels: ["r", "g", "b"],
42
+ ranges: [
43
+ [0, 1],
44
+ [0, 1],
45
+ [0, 1]
46
+ ],
47
+ white: "D65",
48
+ toXYZ: (coords) => linearP3ToXyz(p3ToLinear(coords)),
49
+ fromXYZ: (xyz) => linearToP3(xyzToLinearP3(xyz))
50
+ });
@@ -0,0 +1,93 @@
1
+ // HSL — the CSS HSL color space, defined against gamma-encoded sRGB.
2
+ //
3
+ // H in [0, 360) degrees; S and L in [0, 100] percent (CSS convention).
4
+ // Ports the conversion math from v2 `_RGBAToHSLA` (src/swatch.js:808-873) and
5
+ // `_HSLToRGB` (src/swatch.js:546-613) but operates on unit-RGB coords
6
+ // instead of 0..255 ints.
7
+
8
+ import { registerSpace, getSpace } from "../core/registry.js";
9
+
10
+ // Unit-RGB → HSL. rgb coords in [0, 1]; output h in [0, 360), s/l in [0, 100].
11
+ export function srgbToHsl(rgb) {
12
+ const r = rgb[0];
13
+ const g = rgb[1];
14
+ const b = rgb[2];
15
+ const max = Math.max(r, g, b);
16
+ const min = Math.min(r, g, b);
17
+ const delta = max - min;
18
+ let h = 0;
19
+ let s = 0;
20
+ const l = (max + min) / 2;
21
+
22
+ if (delta !== 0) {
23
+ s = delta / (1 - Math.abs(2 * l - 1));
24
+ if (max === r) {
25
+ h = ((g - b) / delta) % 6;
26
+ } else if (max === g) {
27
+ h = (b - r) / delta + 2;
28
+ } else {
29
+ h = (r - g) / delta + 4;
30
+ }
31
+ h *= 60;
32
+ if (h < 0) h += 360;
33
+ }
34
+
35
+ return [h, s * 100, l * 100];
36
+ }
37
+
38
+ // HSL → unit-RGB.
39
+ export function hslToSrgb(hsl) {
40
+ const h = ((hsl[0] % 360) + 360) % 360;
41
+ const s = hsl[1] / 100;
42
+ const l = hsl[2] / 100;
43
+ const c = (1 - Math.abs(2 * l - 1)) * s;
44
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
45
+ const m = l - c / 2;
46
+ let r1 = 0;
47
+ let g1 = 0;
48
+ let b1 = 0;
49
+ if (h < 60) {
50
+ r1 = c;
51
+ g1 = x;
52
+ } else if (h < 120) {
53
+ r1 = x;
54
+ g1 = c;
55
+ } else if (h < 180) {
56
+ g1 = c;
57
+ b1 = x;
58
+ } else if (h < 240) {
59
+ g1 = x;
60
+ b1 = c;
61
+ } else if (h < 300) {
62
+ r1 = x;
63
+ b1 = c;
64
+ } else {
65
+ r1 = c;
66
+ b1 = x;
67
+ }
68
+ return [r1 + m, g1 + m, b1 + m];
69
+ }
70
+
71
+ registerSpace({
72
+ id: "hsl",
73
+ channels: ["h", "s", "l"],
74
+ ranges: [
75
+ [0, 360],
76
+ [0, 100],
77
+ [0, 100]
78
+ ],
79
+ white: "D65",
80
+ toXYZ: (coords) => {
81
+ const rgb = hslToSrgb(coords);
82
+ return getSpace("srgb").toXYZ(rgb);
83
+ },
84
+ fromXYZ: (xyz) => {
85
+ const rgb = getSpace("srgb").fromXYZ(xyz);
86
+ return srgbToHsl(rgb);
87
+ },
88
+ shortcuts: {
89
+ srgb: (coords) => hslToSrgb(coords)
90
+ }
91
+ });
92
+
93
+ getSpace("srgb").shortcuts.hsl = (coords) => srgbToHsl(coords);
@@ -0,0 +1,211 @@
1
+ // HSLuv — perceptually uniform alternative to HSL.
2
+ //
3
+ // Ported from the canonical reference at https://github.com/hsluv/hsluv
4
+ // (Alexei Boronine, MIT License). HSLuv is CIELuv under a polar
5
+ // transform with the chroma normalized against the maximum possible
6
+ // chroma for each (L, H) in the sRGB gamut — so S = 100 always means
7
+ // "as saturated as sRGB will allow" at that lightness/hue, and lines
8
+ // of constant L are perceptually flat.
9
+ //
10
+ // The intermediate CIELuv space is registered too (as id 'luv').
11
+ //
12
+ // We register HSLuv with a direct srgb shortcut so there is no
13
+ // precision-losing round-trip through the generic XYZ hub when the
14
+ // user reads `.hsluv` or calls `.to('hsluv')` from an sRGB source.
15
+
16
+ import { registerSpace, getSpace } from "../core/registry.js";
17
+
18
+ // sRGB linear ↔ XYZ matrices per the HSLuv reference (BT.709,
19
+ // numerically identical to src/spaces/srgb.js within ~1e-6).
20
+ const M = [
21
+ [3.240969941904521, -1.537383177570093, -0.498610760293],
22
+ [-0.96924363628087, 1.87596750150772, 0.041555057407175],
23
+ [0.055630079696993, -0.20397695888897, 1.056971514242878]
24
+ ];
25
+ const M_INV = [
26
+ [0.41239079926595, 0.35758433938387, 0.18048078840183],
27
+ [0.21263900587151, 0.71516867876775, 0.07219231536073],
28
+ [0.01933081871559, 0.11919477979462, 0.9505321522496]
29
+ ];
30
+
31
+ const REF_U = 0.19783000664283;
32
+ const REF_V = 0.46831999493879;
33
+ const KAPPA = 903.2962962;
34
+ const EPSILON = 0.0088564516;
35
+
36
+ function dotProduct(row, v) {
37
+ return row[0] * v[0] + row[1] * v[1] + row[2] * v[2];
38
+ }
39
+
40
+ function srgbLinearToRgb(lin) {
41
+ // Matches the sRGB EOTF; copied here so hsluv is self-contained.
42
+ const f = (v) =>
43
+ Math.abs(v) <= 0.0031308
44
+ ? 12.92 * v
45
+ : Math.sign(v) * (1.055 * Math.pow(Math.abs(v), 1 / 2.4) - 0.055);
46
+ return [f(lin[0]), f(lin[1]), f(lin[2])];
47
+ }
48
+
49
+ function rgbToSrgbLinear(rgb) {
50
+ const f = (v) =>
51
+ Math.abs(v) <= 0.04045
52
+ ? v / 12.92
53
+ : Math.sign(v) * Math.pow((Math.abs(v) + 0.055) / 1.055, 2.4);
54
+ return [f(rgb[0]), f(rgb[1]), f(rgb[2])];
55
+ }
56
+
57
+ // Get the 6 lines in the (u, v) plane that bound the sRGB gamut for
58
+ // the given L value.
59
+ function getBounds(L) {
60
+ const result = [];
61
+ const sub1 = Math.pow(L + 16, 3) / 1560896;
62
+ const sub2 = sub1 > EPSILON ? sub1 : L / KAPPA;
63
+ for (let channel = 0; channel < 3; channel++) {
64
+ const m1 = M[channel][0];
65
+ const m2 = M[channel][1];
66
+ const m3 = M[channel][2];
67
+ for (let t = 0; t < 2; t++) {
68
+ const top1 = (284517 * m1 - 94839 * m3) * sub2;
69
+ const top2 =
70
+ (838422 * m3 + 769860 * m2 + 731718 * m1) * L * sub2 - 769860 * t * L;
71
+ const bottom = (632260 * m3 - 126452 * m2) * sub2 + 126452 * t;
72
+ result.push({ slope: top1 / bottom, intercept: top2 / bottom });
73
+ }
74
+ }
75
+ return result;
76
+ }
77
+
78
+ function lengthOfRayUntilIntersect(theta, line) {
79
+ return line.intercept / (Math.sin(theta) - line.slope * Math.cos(theta));
80
+ }
81
+
82
+ function maxChromaForLH(L, H) {
83
+ const hrad = (H / 360) * Math.PI * 2;
84
+ let min = Infinity;
85
+ for (const bound of getBounds(L)) {
86
+ const length = lengthOfRayUntilIntersect(hrad, bound);
87
+ if (length >= 0 && length < min) min = length;
88
+ }
89
+ return min;
90
+ }
91
+
92
+ function yToL(Y) {
93
+ if (Y <= EPSILON) return Y * KAPPA;
94
+ return 116 * Math.cbrt(Y) - 16;
95
+ }
96
+
97
+ function lToY(L) {
98
+ if (L <= 8) return L / KAPPA;
99
+ return Math.pow((L + 16) / 116, 3);
100
+ }
101
+
102
+ // XYZ (D65, Y=1 for white) → CIELuv.
103
+ export function xyzToLuv(xyz) {
104
+ const [X, Y, Z] = xyz;
105
+ const divisor = X + 15 * Y + 3 * Z;
106
+ if (divisor === 0) return [0, 0, 0];
107
+ const varU = (4 * X) / divisor;
108
+ const varV = (9 * Y) / divisor;
109
+ const L = yToL(Y);
110
+ if (L === 0) return [0, 0, 0];
111
+ const U = 13 * L * (varU - REF_U);
112
+ const V = 13 * L * (varV - REF_V);
113
+ return [L, U, V];
114
+ }
115
+
116
+ export function luvToXyz(luv) {
117
+ const [L, U, V] = luv;
118
+ if (L === 0) return [0, 0, 0];
119
+ const varU = U / (13 * L) + REF_U;
120
+ const varV = V / (13 * L) + REF_V;
121
+ const Y = lToY(L);
122
+ const X = -(9 * Y * varU) / ((varU - 4) * varV - varU * varV);
123
+ const Z = (9 * Y - 15 * varV * Y - varV * X) / (3 * varV);
124
+ return [X, Y, Z];
125
+ }
126
+
127
+ function luvToLchuv(luv) {
128
+ const [L, U, V] = luv;
129
+ const C = Math.sqrt(U * U + V * V);
130
+ let H;
131
+ if (C < 1e-8) {
132
+ H = 0;
133
+ } else {
134
+ H = (Math.atan2(V, U) * 180) / Math.PI;
135
+ if (H < 0) H += 360;
136
+ }
137
+ return [L, C, H];
138
+ }
139
+
140
+ function lchuvToLuv(lch) {
141
+ const [L, C, H] = lch;
142
+ const hrad = (H * Math.PI) / 180;
143
+ return [L, Math.cos(hrad) * C, Math.sin(hrad) * C];
144
+ }
145
+
146
+ function hsluvToLchuv(hsl) {
147
+ const [H, S, L] = hsl;
148
+ if (L > 99.9999999) return [100, 0, H];
149
+ if (L < 0.00000001) return [0, 0, H];
150
+ const max = maxChromaForLH(L, H);
151
+ const C = (max / 100) * S;
152
+ return [L, C, H];
153
+ }
154
+
155
+ function lchuvToHsluv(lch) {
156
+ const [L, C, H] = lch;
157
+ if (L > 99.9999999) return [H, 0, 100];
158
+ if (L < 0.00000001) return [H, 0, 0];
159
+ const max = maxChromaForLH(L, H);
160
+ const S = (C / max) * 100;
161
+ return [H, S, L];
162
+ }
163
+
164
+ // HSLuv → sRGB (using HSLuv's internal matrices).
165
+ export function hsluvToSrgb(hsluv) {
166
+ const lch = hsluvToLchuv(hsluv);
167
+ const luv = lchuvToLuv(lch);
168
+ const xyz = luvToXyz(luv);
169
+ const lin = [dotProduct(M[0], xyz), dotProduct(M[1], xyz), dotProduct(M[2], xyz)];
170
+ return srgbLinearToRgb(lin);
171
+ }
172
+
173
+ export function srgbToHsluv(rgb) {
174
+ const lin = rgbToSrgbLinear(rgb);
175
+ const xyz = [
176
+ dotProduct(M_INV[0], lin),
177
+ dotProduct(M_INV[1], lin),
178
+ dotProduct(M_INV[2], lin)
179
+ ];
180
+ const luv = xyzToLuv(xyz);
181
+ const lch = luvToLchuv(luv);
182
+ return lchuvToHsluv(lch);
183
+ }
184
+
185
+ // Register CIELuv as a standalone space.
186
+ registerSpace({
187
+ id: "luv",
188
+ channels: ["l", "u", "v"],
189
+ white: "D65",
190
+ toXYZ: (coords) => luvToXyz(coords),
191
+ fromXYZ: (xyz) => xyzToLuv(xyz)
192
+ });
193
+
194
+ // Register HSLuv with a direct sRGB shortcut.
195
+ registerSpace({
196
+ id: "hsluv",
197
+ channels: ["h", "s", "l"],
198
+ ranges: [
199
+ [0, 360],
200
+ [0, 100],
201
+ [0, 100]
202
+ ],
203
+ white: "D65",
204
+ toXYZ: (coords) => getSpace("srgb").toXYZ(hsluvToSrgb(coords)),
205
+ fromXYZ: (xyz) => srgbToHsluv(getSpace("srgb").fromXYZ(xyz)),
206
+ shortcuts: {
207
+ srgb: (coords) => hsluvToSrgb(coords)
208
+ }
209
+ });
210
+
211
+ getSpace("srgb").shortcuts.hsluv = (coords) => srgbToHsluv(coords);
@@ -0,0 +1,78 @@
1
+ // HSV / HSB space (Hue, Saturation, Value/Brightness).
2
+ //
3
+ // Defined on top of sRGB: H in degrees, S and V in 0..100 (percent-
4
+ // like units) to match the conventions used by the rest of the library.
5
+ //
6
+ // Bidirectional shortcut to sRGB bypasses the XYZ hub. toXYZ / fromXYZ
7
+ // still route through sRGB for the conversion graph fallback.
8
+
9
+ import { registerSpace, getSpace } from "../core/registry.js";
10
+
11
+ export function srgbToHsv(rgb) {
12
+ const [r, g, b] = rgb;
13
+ const max = Math.max(r, g, b);
14
+ const min = Math.min(r, g, b);
15
+ const delta = max - min;
16
+ let h = 0;
17
+ if (delta !== 0) {
18
+ if (max === r) h = 60 * (((g - b) / delta) % 6);
19
+ else if (max === g) h = 60 * ((b - r) / delta + 2);
20
+ else h = 60 * ((r - g) / delta + 4);
21
+ }
22
+ if (h < 0) h += 360;
23
+ const s = max === 0 ? 0 : (delta / max) * 100;
24
+ const v = max * 100;
25
+ return [h, s, v];
26
+ }
27
+
28
+ export function hsvToSrgb(hsv) {
29
+ const [h, sPct, vPct] = hsv;
30
+ const s = sPct / 100;
31
+ const v = vPct / 100;
32
+ const c = v * s;
33
+ const hp = ((h % 360) + 360) % 360 / 60;
34
+ const x = c * (1 - Math.abs((hp % 2) - 1));
35
+ let r1 = 0,
36
+ g1 = 0,
37
+ b1 = 0;
38
+ if (hp < 1) {
39
+ r1 = c;
40
+ g1 = x;
41
+ } else if (hp < 2) {
42
+ r1 = x;
43
+ g1 = c;
44
+ } else if (hp < 3) {
45
+ g1 = c;
46
+ b1 = x;
47
+ } else if (hp < 4) {
48
+ g1 = x;
49
+ b1 = c;
50
+ } else if (hp < 5) {
51
+ r1 = x;
52
+ b1 = c;
53
+ } else {
54
+ r1 = c;
55
+ b1 = x;
56
+ }
57
+ const m = v - c;
58
+ return [r1 + m, g1 + m, b1 + m];
59
+ }
60
+
61
+ registerSpace({
62
+ id: "hsv",
63
+ channels: ["h", "s", "v"],
64
+ ranges: [
65
+ [0, 360],
66
+ [0, 100],
67
+ [0, 100]
68
+ ],
69
+ white: "D65",
70
+ toXYZ: (coords) => getSpace("srgb").toXYZ(hsvToSrgb(coords)),
71
+ fromXYZ: (xyz) => srgbToHsv(getSpace("srgb").fromXYZ(xyz)),
72
+ shortcuts: {
73
+ srgb: (coords) => hsvToSrgb(coords)
74
+ }
75
+ });
76
+
77
+ // Patch reverse shortcut on sRGB now that HSV exists.
78
+ getSpace("srgb").shortcuts.hsv = (coords) => srgbToHsv(coords);
@@ -0,0 +1,48 @@
1
+ // HWB (Hue, Whiteness, Blackness) — CSS Color 4 definition.
2
+ //
3
+ // H in degrees (same as HSL/HSV); W and B are percentage-like 0..100
4
+ // units representing how much white / black is mixed in. When W + B
5
+ // >= 100, the result is a neutral grey W/(W+B).
6
+ //
7
+ // Converts through HSV (sharing the hue) per the CSS spec.
8
+
9
+ import { registerSpace, getSpace } from "../core/registry.js";
10
+ import { srgbToHsv, hsvToSrgb } from "./hsv.js";
11
+
12
+ export function srgbToHwb(rgb) {
13
+ const [h] = srgbToHsv(rgb);
14
+ const w = Math.min(rgb[0], rgb[1], rgb[2]) * 100;
15
+ const b = (1 - Math.max(rgb[0], rgb[1], rgb[2])) * 100;
16
+ return [h, w, b];
17
+ }
18
+
19
+ export function hwbToSrgb(hwb) {
20
+ const [h, wPct, blPct] = hwb;
21
+ const w = wPct / 100;
22
+ const bl = blPct / 100;
23
+ if (w + bl >= 1) {
24
+ const gray = w / (w + bl);
25
+ return [gray, gray, gray];
26
+ }
27
+ const base = hsvToSrgb([h, 100, 100]);
28
+ const scale = 1 - w - bl;
29
+ return [base[0] * scale + w, base[1] * scale + w, base[2] * scale + w];
30
+ }
31
+
32
+ registerSpace({
33
+ id: "hwb",
34
+ channels: ["h", "w", "b"],
35
+ ranges: [
36
+ [0, 360],
37
+ [0, 100],
38
+ [0, 100]
39
+ ],
40
+ white: "D65",
41
+ toXYZ: (coords) => getSpace("srgb").toXYZ(hwbToSrgb(coords)),
42
+ fromXYZ: (xyz) => srgbToHwb(getSpace("srgb").fromXYZ(xyz)),
43
+ shortcuts: {
44
+ srgb: (coords) => hwbToSrgb(coords)
45
+ }
46
+ });
47
+
48
+ getSpace("srgb").shortcuts.hwb = (coords) => srgbToHwb(coords);
@@ -0,0 +1,70 @@
1
+ // CIE Lab color space.
2
+ //
3
+ // Two variants are registered:
4
+ //
5
+ // lab — D65 reference white (our default; matches what v2 used).
6
+ // lab-d50 — D50 reference white (the CSS Color 4 specified variant).
7
+ //
8
+ // Both follow the standard CIE L*a*b* transfer functions. L is on a 0..100
9
+ // scale; a and b are signed.
10
+ //
11
+ // Porting reference: v2 `_XYZToLab` at src/swatch.js:1463-1482 and `_labToRGB`
12
+ // at src/swatch.js:1098-1128.
13
+
14
+ import { registerSpace } from "../core/registry.js";
15
+ import { D65, D50, adaptD50ToD65, adaptD65ToD50 } from "./xyz.js";
16
+
17
+ const KAPPA = 24389 / 27; // (29/3)^3
18
+ const EPSILON = 216 / 24389; // (6/29)^3
19
+ const DELTA = 6 / 29;
20
+
21
+ function labForward(xyz, white) {
22
+ const f = (t) =>
23
+ t > EPSILON ? Math.cbrt(t) : (KAPPA * t + 16) / 116;
24
+ const fx = f(xyz[0] / white[0]);
25
+ const fy = f(xyz[1] / white[1]);
26
+ const fz = f(xyz[2] / white[2]);
27
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
28
+ }
29
+
30
+ function labInverse(lab, white) {
31
+ const fy = (lab[0] + 16) / 116;
32
+ const fx = lab[1] / 500 + fy;
33
+ const fz = fy - lab[2] / 200;
34
+ const finv = (t) =>
35
+ t > DELTA ? t * t * t : 3 * DELTA * DELTA * (t - 4 / 29);
36
+ return [
37
+ white[0] * finv(fx),
38
+ white[1] * finv(fy),
39
+ white[2] * finv(fz)
40
+ ];
41
+ }
42
+
43
+ // Lab D65 — the v2 default; all existing tests expect this.
44
+ registerSpace({
45
+ id: "lab",
46
+ channels: ["l", "a", "b"],
47
+ ranges: [
48
+ [0, 100],
49
+ [-125, 125],
50
+ [-125, 125]
51
+ ],
52
+ white: "D65",
53
+ toXYZ: (coords) => labInverse(coords, D65),
54
+ fromXYZ: (xyz) => labForward(xyz, D65)
55
+ });
56
+
57
+ // Lab D50 — CSS Color 4 lab() uses this. Bradford-adapts to D65 on the
58
+ // way to the hub.
59
+ registerSpace({
60
+ id: "lab-d50",
61
+ channels: ["l", "a", "b"],
62
+ ranges: [
63
+ [0, 100],
64
+ [-125, 125],
65
+ [-125, 125]
66
+ ],
67
+ white: "D50",
68
+ toXYZ: (coords) => adaptD50ToD65(labInverse(coords, D50)),
69
+ fromXYZ: (xyz) => labForward(adaptD65ToD50(xyz), D50)
70
+ });