@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,80 @@
1
+ // APCA — Accessible Perceptual Contrast Algorithm (Andrew Somers).
2
+ //
3
+ // Returns the Lightness Contrast (Lc) on a ~[-108, +106] scale:
4
+ // • Positive Lc — dark text on light background ("BoW")
5
+ // • Negative Lc — light text on dark background ("WoB")
6
+ //
7
+ // Typical body-text thresholds: |Lc| ≥ 75 comfortable, ≥ 60 minimum.
8
+ // Reference: https://github.com/Myndex/SAPC-APCA (SA98G constants).
9
+ // Uses the "simple" γ = 2.4 model, not the full sRGB EOTF, matching
10
+ // the spec.
11
+
12
+ import { Swatch, swatch } from "../core/swatch-class.js";
13
+ import { inGamut, toGamut } from "./gamut.js";
14
+
15
+ function toSwatch(input) {
16
+ return input instanceof Swatch ? input : swatch(input);
17
+ }
18
+
19
+ function toDisplaySrgb(input) {
20
+ const s = toSwatch(input);
21
+ if (inGamut(s, "srgb")) return s.to("srgb");
22
+ return toGamut(s, { space: "srgb" });
23
+ }
24
+
25
+ const mainTRC = 2.4;
26
+ const sRco = 0.2126729;
27
+ const sGco = 0.7151522;
28
+ const sBco = 0.072175;
29
+
30
+ function apcaY({ r, g, b }) {
31
+ return (
32
+ sRco * Math.pow(r, mainTRC) +
33
+ sGco * Math.pow(g, mainTRC) +
34
+ sBco * Math.pow(b, mainTRC)
35
+ );
36
+ }
37
+
38
+ // Soft clamp for luminances near black.
39
+ const blkThrs = 0.022;
40
+ const blkClmp = 1.414;
41
+ function clampY(Y) {
42
+ return Y >= blkThrs ? Y : Y + Math.pow(blkThrs - Y, blkClmp);
43
+ }
44
+
45
+ const deltaYmin = 0.0005;
46
+ const normBG = 0.56;
47
+ const normTXT = 0.57;
48
+ const revTXT = 0.62;
49
+ const revBG = 0.65;
50
+ const scaleBoW = 1.14;
51
+ const scaleWoB = 1.14;
52
+ const loBoWoffset = 0.027;
53
+ const loWoBoffset = 0.027;
54
+ const loClip = 0.1;
55
+
56
+ export function apcaContrast(text, background) {
57
+ const t = toDisplaySrgb(text);
58
+ const b = toDisplaySrgb(background);
59
+
60
+ const txtY = clampY(apcaY(t.srgb));
61
+ const bgY = clampY(apcaY(b.srgb));
62
+
63
+ if (Math.abs(bgY - txtY) < deltaYmin) return 0;
64
+
65
+ let SAPC;
66
+ let outputContrast;
67
+ if (bgY > txtY) {
68
+ // BoW — light background, dark text (positive Lc).
69
+ SAPC =
70
+ (Math.pow(bgY, normBG) - Math.pow(txtY, normTXT)) * scaleBoW;
71
+ outputContrast = SAPC < loClip ? 0 : SAPC - loBoWoffset;
72
+ } else {
73
+ // WoB — dark background, light text (negative Lc).
74
+ SAPC =
75
+ (Math.pow(bgY, revBG) - Math.pow(txtY, revTXT)) * scaleWoB;
76
+ outputContrast = SAPC > -loClip ? 0 : SAPC + loWoBoffset;
77
+ }
78
+
79
+ return outputContrast * 100;
80
+ }
@@ -0,0 +1,72 @@
1
+ // Blend modes.
2
+ //
3
+ // The W3C "Compositing and Blending Level 1" non-separable blend modes
4
+ // (minus hue/saturation/color/luminosity, which require a separate HSL
5
+ // pass and aren't covered by the acceptance criteria). Operates on
6
+ // sRGB-encoded channels per the CSS default blending color space —
7
+ // this matches Canvas2D/Photoshop/CSS `mix-blend-mode` behavior.
8
+ //
9
+ // Alpha composition: for simplicity we combine alphas as `a_src +
10
+ // a_dst - a_src * a_dst` (normal compositing); blending only affects
11
+ // color channels.
12
+
13
+ import { Swatch, swatch } from "../core/swatch-class.js";
14
+
15
+ function clamp01(v) {
16
+ return v < 0 ? 0 : v > 1 ? 1 : v;
17
+ }
18
+
19
+ const MODES = {
20
+ normal: (_cb, cs) => cs,
21
+ multiply: (cb, cs) => cb * cs,
22
+ screen: (cb, cs) => cb + cs - cb * cs,
23
+ darken: (cb, cs) => Math.min(cb, cs),
24
+ lighten: (cb, cs) => Math.max(cb, cs),
25
+ difference: (cb, cs) => Math.abs(cb - cs),
26
+ exclusion: (cb, cs) => cb + cs - 2 * cb * cs,
27
+ "color-dodge": (cb, cs) => {
28
+ if (cb === 0) return 0;
29
+ if (cs === 1) return 1;
30
+ return Math.min(1, cb / (1 - cs));
31
+ },
32
+ "color-burn": (cb, cs) => {
33
+ if (cb === 1) return 1;
34
+ if (cs === 0) return 0;
35
+ return 1 - Math.min(1, (1 - cb) / cs);
36
+ },
37
+ "hard-light": (cb, cs) => {
38
+ if (cs <= 0.5) return cb * cs * 2;
39
+ return cb + (2 * cs - 1) - cb * (2 * cs - 1);
40
+ },
41
+ "soft-light": (cb, cs) => {
42
+ if (cs <= 0.5) {
43
+ return cb - (1 - 2 * cs) * cb * (1 - cb);
44
+ }
45
+ const d =
46
+ cb <= 0.25 ? ((16 * cb - 12) * cb + 4) * cb : Math.sqrt(cb);
47
+ return cb + (2 * cs - 1) * (d - cb);
48
+ },
49
+ overlay: (cb, cs) => MODES["hard-light"](cs, cb)
50
+ };
51
+
52
+ export function listBlendModes() {
53
+ return Object.keys(MODES);
54
+ }
55
+
56
+ export function blend(backdrop, source, mode = "normal") {
57
+ const fn = MODES[mode];
58
+ if (!fn) throw new Error(`blend: unknown mode "${mode}"`);
59
+ const bs = backdrop instanceof Swatch ? backdrop : swatch(backdrop);
60
+ const ss = source instanceof Swatch ? source : swatch(source);
61
+ const b = bs.srgb;
62
+ const s = ss.srgb;
63
+ const r = clamp01(fn(b.r, s.r));
64
+ const g = clamp01(fn(b.g, s.g));
65
+ const bl = clamp01(fn(b.b, s.b));
66
+ const alpha = bs.alpha + ss.alpha - bs.alpha * ss.alpha;
67
+ return new Swatch({
68
+ space: "srgb",
69
+ coords: [r, g, bl],
70
+ alpha
71
+ });
72
+ }
@@ -0,0 +1,123 @@
1
+ // Channel get/set by path.
2
+ //
3
+ // Paths are either the literal `'alpha'`, or `'<space>.<channel>'` where
4
+ // space is a registered space id (with `'rgb'` as an alias for `'srgb'`)
5
+ // and channel is one of the space's channel names.
6
+ //
7
+ // `get(swatch, path)` returns the current value — converting to the
8
+ // target space if necessary. `set(swatch, path, value)` returns a NEW
9
+ // Swatch with the channel updated; the result's source space is the
10
+ // space named in the path (so `set(red, 'oklch.l', 0.5).space === 'oklch'`).
11
+
12
+ import { Swatch } from "../core/swatch-class.js";
13
+ import { getSpace, hasSpace, listSpaces } from "../core/registry.js";
14
+ import { appendSuggestion, closestMatch } from "../util/suggest.js";
15
+
16
+ const SPACE_ALIASES = {
17
+ rgb: "srgb"
18
+ };
19
+
20
+ // Long-form channel names → the single-letter channel they refer to, scoped
21
+ // per channel layout. Scoping matters because the same letter means different
22
+ // things in different spaces: "b" is blue in rgb but blackness in hwb, and in
23
+ // lab/oklab the a/b axes have no common long name at all. A single global map
24
+ // would mis-suggest blackness's "b" for a stray `hwb.blue`.
25
+ const CHANNEL_HINTS_BY_LAYOUT = {
26
+ "r,g,b": { red: "r", green: "g", blue: "b" },
27
+ "h,s,l": { hue: "h", saturation: "s", lightness: "l", luminosity: "l" },
28
+ "h,s,v": { hue: "h", saturation: "s", value: "v", brightness: "v" },
29
+ "h,w,b": { hue: "h", whiteness: "w", white: "w", blackness: "b", black: "b" },
30
+ "l,c,h": { lightness: "l", luminance: "l", chroma: "c", hue: "h" },
31
+ "c,m,y,k": { cyan: "c", magenta: "m", yellow: "y", black: "k", key: "k" }
32
+ };
33
+
34
+ function foldCmyk({ c, m, y, k }) {
35
+ return [c + k - c * k, m + k - m * k, y + k - y * k];
36
+ }
37
+
38
+ function resolveSpaceId(token) {
39
+ if (SPACE_ALIASES[token]) return SPACE_ALIASES[token];
40
+ return token;
41
+ }
42
+
43
+ function suggestChannel(channel, channels) {
44
+ const table = CHANNEL_HINTS_BY_LAYOUT[channels.join(",")];
45
+ const hinted = table?.[channel];
46
+ if (hinted && channels.includes(hinted)) return hinted;
47
+ return closestMatch(channel, channels);
48
+ }
49
+
50
+ function parsePath(path) {
51
+ if (typeof path !== "string" || path.length === 0) {
52
+ throw new Error("channel path: expected non-empty string");
53
+ }
54
+ if (path === "alpha") {
55
+ return { kind: "alpha" };
56
+ }
57
+ const dotIdx = path.indexOf(".");
58
+ if (dotIdx < 0) {
59
+ throw new Error(
60
+ `channel path: "${path}" must be "<space>.<channel>" or "alpha"`
61
+ );
62
+ }
63
+ const spaceToken = path.slice(0, dotIdx).toLowerCase();
64
+ const channel = path.slice(dotIdx + 1).toLowerCase();
65
+ const spaceId = resolveSpaceId(spaceToken);
66
+ if (!hasSpace(spaceId)) {
67
+ throw new Error(
68
+ appendSuggestion(
69
+ `channel path: unknown space "${spaceToken}"`,
70
+ spaceToken,
71
+ ["rgb", ...listSpaces()]
72
+ )
73
+ );
74
+ }
75
+ if (spaceId === "cmyk" && channel === "k") {
76
+ return { kind: "cmyk-k" };
77
+ }
78
+ const space = getSpace(spaceId);
79
+ const idx = space.channels.indexOf(channel);
80
+ if (idx < 0) {
81
+ const suggestion = suggestChannel(channel, space.channels);
82
+ const maybeSuggestion = suggestion
83
+ ? ` Did you mean "${suggestion}"?`
84
+ : "";
85
+ throw new Error(
86
+ `channel path: space "${spaceId}" has no channel "${channel}".${maybeSuggestion} Valid channels: ${space.channels.join(", ")}.`
87
+ );
88
+ }
89
+ return { kind: "channel", spaceId, channel, index: idx };
90
+ }
91
+
92
+ export function getChannel(swatch, path) {
93
+ const parsed = parsePath(path);
94
+ if (parsed.kind === "alpha") return swatch.alpha;
95
+ if (parsed.kind === "cmyk-k") return swatch.cmyk.k;
96
+ return swatch._getCoordsIn(parsed.spaceId)[parsed.index];
97
+ }
98
+
99
+ export function setChannel(swatch, path, value) {
100
+ const parsed = parsePath(path);
101
+ if (parsed.kind === "alpha") {
102
+ return new Swatch({
103
+ space: swatch.space,
104
+ coords: swatch.coords,
105
+ alpha: value
106
+ });
107
+ }
108
+ if (parsed.kind === "cmyk-k") {
109
+ const cmyk = swatch.cmyk;
110
+ return new Swatch({
111
+ space: "cmyk",
112
+ coords: foldCmyk({ ...cmyk, k: value }),
113
+ alpha: swatch.alpha
114
+ });
115
+ }
116
+ const coords = swatch._getCoordsIn(parsed.spaceId);
117
+ coords[parsed.index] = value;
118
+ return new Swatch({
119
+ space: parsed.spaceId,
120
+ coords,
121
+ alpha: swatch.alpha
122
+ });
123
+ }
@@ -0,0 +1,119 @@
1
+ // Color Vision Deficiency simulation and daltonization.
2
+ //
3
+ // Projection matrices for protan/deutan/tritan come from
4
+ // data/cvd-matrices.js via the Brettel/Viénot anchor-line method.
5
+ // simulate() interpolates between the identity and the dichromat
6
+ // matrix by `severity` (0..1). daltonize() uses Fidaner-style error
7
+ // redistribution: compute what the dichromat loses (the delta in
8
+ // linear sRGB) and shift it into channels the user can still see.
9
+
10
+ import { Swatch, swatch } from "../core/swatch-class.js";
11
+ import { srgbToLinear, linearToSrgb } from "../spaces/srgb.js";
12
+ import {
13
+ CVD_RGB_MATRICES,
14
+ IDENTITY3,
15
+ ACHROMA_MATRIX,
16
+ interpolateMatrix3,
17
+ normalizeCVDType
18
+ } from "../data/cvd-matrices.js";
19
+
20
+ function toSwatch(input) {
21
+ return input instanceof Swatch ? input : swatch(input);
22
+ }
23
+
24
+ function mat3mulVec3(M, v) {
25
+ return [
26
+ M[0][0] * v[0] + M[0][1] * v[1] + M[0][2] * v[2],
27
+ M[1][0] * v[0] + M[1][1] * v[1] + M[1][2] * v[2],
28
+ M[2][0] * v[0] + M[2][1] * v[1] + M[2][2] * v[2]
29
+ ];
30
+ }
31
+
32
+ function clamp01Triplet(v) {
33
+ for (let i = 0; i < 3; i++) {
34
+ if (v[i] < 0) v[i] = 0;
35
+ else if (v[i] > 1) v[i] = 1;
36
+ }
37
+ return v;
38
+ }
39
+
40
+ export function simulate(input, type, { severity = 1 } = {}) {
41
+ const s = toSwatch(input);
42
+ const sev = Math.max(0, Math.min(1, severity));
43
+ const normalized = normalizeCVDType(type);
44
+
45
+ let M;
46
+ if (normalized === "achroma") {
47
+ M = interpolateMatrix3(IDENTITY3, ACHROMA_MATRIX, sev);
48
+ } else {
49
+ M = interpolateMatrix3(
50
+ IDENTITY3,
51
+ CVD_RGB_MATRICES[normalized],
52
+ sev
53
+ );
54
+ }
55
+
56
+ const { r, g, b } = s.srgb;
57
+ const lin = srgbToLinear([r, g, b]);
58
+ const linOut = clamp01Triplet(mat3mulVec3(M, lin));
59
+ const out = linearToSrgb(linOut);
60
+
61
+ return new Swatch({
62
+ space: "srgb",
63
+ coords: out,
64
+ alpha: s.alpha
65
+ });
66
+ }
67
+
68
+ export function daltonize(input, type, { severity = 1 } = {}) {
69
+ const s = toSwatch(input);
70
+ const sev = Math.max(0, Math.min(1, severity));
71
+ const normalized = normalizeCVDType(type);
72
+ if (normalized === "achroma") {
73
+ throw new Error(
74
+ "daltonize: achromatopsia cannot be corrected (no remaining channels)"
75
+ );
76
+ }
77
+
78
+ const M = interpolateMatrix3(
79
+ IDENTITY3,
80
+ CVD_RGB_MATRICES[normalized],
81
+ sev
82
+ );
83
+
84
+ const { r, g, b } = s.srgb;
85
+ const lin = srgbToLinear([r, g, b]);
86
+ const linSim = mat3mulVec3(M, lin);
87
+ const err = [lin[0] - linSim[0], lin[1] - linSim[1], lin[2] - linSim[2]];
88
+
89
+ // Fidaner shift matrices:
90
+ // red-green deficits push red-channel error into G and B;
91
+ // blue-yellow (tritan) pushes blue error into R and G.
92
+ let shift;
93
+ if (normalized === "tritan") {
94
+ shift = [
95
+ [0, 0, 0.7],
96
+ [0, 0, 0.7],
97
+ [0, 0, 0]
98
+ ];
99
+ } else {
100
+ shift = [
101
+ [0, 0, 0],
102
+ [0.7, 0, 0],
103
+ [0.7, 0, 0]
104
+ ];
105
+ }
106
+
107
+ const corrected = clamp01Triplet([
108
+ lin[0] + shift[0][0] * err[0] + shift[0][1] * err[1] + shift[0][2] * err[2],
109
+ lin[1] + shift[1][0] * err[0] + shift[1][1] * err[1] + shift[1][2] * err[2],
110
+ lin[2] + shift[2][0] * err[0] + shift[2][1] * err[1] + shift[2][2] * err[2]
111
+ ]);
112
+
113
+ const out = linearToSrgb(corrected);
114
+ return new Swatch({
115
+ space: "srgb",
116
+ coords: out,
117
+ alpha: s.alpha
118
+ });
119
+ }
@@ -0,0 +1,207 @@
1
+ // Color difference metrics.
2
+ //
3
+ // deltaE(a, b, mode)
4
+ // "76" — CIE76 euclidean distance in CIE Lab D65
5
+ // "94" — CIE94 (graphic arts, kL=kC=kH=1, K1=0.045, K2=0.015)
6
+ // "2000" — CIEDE2000 (Sharma, Wu, Dalal 2005), kL = kC = kH = 1
7
+ // "cmc" — CMC l:c with default l=1 c=1; override via opts
8
+ // (pass as { mode: 'cmc', l, c }, or deltaE(a,b,'cmc',opts))
9
+ // "hyab" — Abasi/Fairchild HyAB for large color differences
10
+ // "ok" — euclidean distance in OKLab
11
+ //
12
+ // All Lab-based metrics operate on D65 Lab (matching v2 behavior
13
+ // and the colorjs.io convention).
14
+
15
+ import { Swatch, swatch } from "../core/swatch-class.js";
16
+
17
+ function toSwatch(input) {
18
+ return input instanceof Swatch ? input : swatch(input);
19
+ }
20
+
21
+ function lab(s) {
22
+ return s._getCoordsIn("lab");
23
+ }
24
+ function oklab(s) {
25
+ return s._getCoordsIn("oklab");
26
+ }
27
+
28
+ export function deltaE76(a, b) {
29
+ const [l1, a1, b1] = lab(toSwatch(a));
30
+ const [l2, a2, b2] = lab(toSwatch(b));
31
+ return Math.hypot(l1 - l2, a1 - a2, b1 - b2);
32
+ }
33
+
34
+ export function deltaEOK(a, b) {
35
+ const [l1, a1, b1] = oklab(toSwatch(a));
36
+ const [l2, a2, b2] = oklab(toSwatch(b));
37
+ return Math.hypot(l1 - l2, a1 - a2, b1 - b2);
38
+ }
39
+
40
+ // CIEDE2000 — Sharma, Wu, Dalal (2005).
41
+ export function deltaE2000(a, b) {
42
+ const [L1, a1, b1] = lab(toSwatch(a));
43
+ const [L2, a2, b2] = lab(toSwatch(b));
44
+ const deg = Math.PI / 180;
45
+
46
+ const C1 = Math.hypot(a1, b1);
47
+ const C2 = Math.hypot(a2, b2);
48
+ const Cbar = (C1 + C2) / 2;
49
+
50
+ const Cbar7 = Math.pow(Cbar, 7);
51
+ const G = 0.5 * (1 - Math.sqrt(Cbar7 / (Cbar7 + Math.pow(25, 7))));
52
+
53
+ const a1p = (1 + G) * a1;
54
+ const a2p = (1 + G) * a2;
55
+
56
+ const C1p = Math.hypot(a1p, b1);
57
+ const C2p = Math.hypot(a2p, b2);
58
+
59
+ let h1p = (Math.atan2(b1, a1p) * 180) / Math.PI;
60
+ if (h1p < 0) h1p += 360;
61
+ let h2p = (Math.atan2(b2, a2p) * 180) / Math.PI;
62
+ if (h2p < 0) h2p += 360;
63
+
64
+ const dLp = L2 - L1;
65
+ const dCp = C2p - C1p;
66
+
67
+ let dhp;
68
+ if (C1p * C2p === 0) {
69
+ dhp = 0;
70
+ } else if (Math.abs(h2p - h1p) <= 180) {
71
+ dhp = h2p - h1p;
72
+ } else if (h2p - h1p > 180) {
73
+ dhp = h2p - h1p - 360;
74
+ } else {
75
+ dhp = h2p - h1p + 360;
76
+ }
77
+ const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp / 2) * deg);
78
+
79
+ const LbarP = (L1 + L2) / 2;
80
+ const CbarP = (C1p + C2p) / 2;
81
+
82
+ let hbarP;
83
+ if (C1p * C2p === 0) {
84
+ hbarP = h1p + h2p;
85
+ } else if (Math.abs(h1p - h2p) <= 180) {
86
+ hbarP = (h1p + h2p) / 2;
87
+ } else if (h1p + h2p < 360) {
88
+ hbarP = (h1p + h2p + 360) / 2;
89
+ } else {
90
+ hbarP = (h1p + h2p - 360) / 2;
91
+ }
92
+
93
+ const T =
94
+ 1 -
95
+ 0.17 * Math.cos((hbarP - 30) * deg) +
96
+ 0.24 * Math.cos(2 * hbarP * deg) +
97
+ 0.32 * Math.cos((3 * hbarP + 6) * deg) -
98
+ 0.2 * Math.cos((4 * hbarP - 63) * deg);
99
+
100
+ const dTheta = 30 * Math.exp(-Math.pow((hbarP - 275) / 25, 2));
101
+
102
+ const CbarP7 = Math.pow(CbarP, 7);
103
+ const Rc = 2 * Math.sqrt(CbarP7 / (CbarP7 + Math.pow(25, 7)));
104
+
105
+ const Sl =
106
+ 1 +
107
+ (0.015 * Math.pow(LbarP - 50, 2)) /
108
+ Math.sqrt(20 + Math.pow(LbarP - 50, 2));
109
+ const Sc = 1 + 0.045 * CbarP;
110
+ const Sh = 1 + 0.015 * CbarP * T;
111
+
112
+ const Rt = -Math.sin(2 * dTheta * deg) * Rc;
113
+
114
+ const kL = 1;
115
+ const kC = 1;
116
+ const kH = 1;
117
+ const termL = dLp / (kL * Sl);
118
+ const termC = dCp / (kC * Sc);
119
+ const termH = dHp / (kH * Sh);
120
+
121
+ return Math.sqrt(
122
+ termL * termL +
123
+ termC * termC +
124
+ termH * termH +
125
+ Rt * termC * termH
126
+ );
127
+ }
128
+
129
+ // CIE94 — graphic-arts application (textiles uses different constants).
130
+ // kL = kC = kH = 1, K1 = 0.045, K2 = 0.015.
131
+ // Symmetric by using sample 1 as reference (v2 convention).
132
+ export function deltaE94(a, b) {
133
+ const [L1, a1, b1] = lab(toSwatch(a));
134
+ const [L2, a2, b2] = lab(toSwatch(b));
135
+
136
+ const C1 = Math.hypot(a1, b1);
137
+ const C2 = Math.hypot(a2, b2);
138
+
139
+ const dL = L1 - L2;
140
+ const dC = C1 - C2;
141
+ const da = a1 - a2;
142
+ const db = b1 - b2;
143
+ // dH² = da² + db² − dC². Guard against tiny negatives from rounding.
144
+ const dH2 = Math.max(da * da + db * db - dC * dC, 0);
145
+
146
+ const SL = 1;
147
+ const SC = 1 + 0.045 * C1;
148
+ const SH = 1 + 0.015 * C1;
149
+
150
+ const termL = dL / SL;
151
+ const termC = dC / SC;
152
+ // termH² = dH² / SH²
153
+ return Math.sqrt(termL * termL + termC * termC + dH2 / (SH * SH));
154
+ }
155
+
156
+ // CMC l:c — sample 1 is reference. Defaults l=1 c=1 ("acceptability");
157
+ // pass l=2 for "perceptibility".
158
+ export function deltaECMC(a, b, { l = 1, c = 1 } = {}) {
159
+ const [L1, a1, b1] = lab(toSwatch(a));
160
+ const [L2, a2, b2] = lab(toSwatch(b));
161
+ const deg = Math.PI / 180;
162
+
163
+ const C1 = Math.hypot(a1, b1);
164
+ const C2 = Math.hypot(a2, b2);
165
+
166
+ let H1 = (Math.atan2(b1, a1) * 180) / Math.PI;
167
+ if (H1 < 0) H1 += 360;
168
+
169
+ const dL = L1 - L2;
170
+ const dC = C1 - C2;
171
+ const da = a1 - a2;
172
+ const db = b1 - b2;
173
+ const dH2 = Math.max(da * da + db * db - dC * dC, 0);
174
+
175
+ const SL = L1 < 16 ? 0.511 : (0.040975 * L1) / (1 + 0.01765 * L1);
176
+ const SC = (0.0638 * C1) / (1 + 0.0131 * C1) + 0.638;
177
+
178
+ const F = Math.sqrt(Math.pow(C1, 4) / (Math.pow(C1, 4) + 1900));
179
+ const T =
180
+ H1 >= 164 && H1 <= 345
181
+ ? 0.56 + Math.abs(0.2 * Math.cos((H1 + 168) * deg))
182
+ : 0.36 + Math.abs(0.4 * Math.cos((H1 + 35) * deg));
183
+ const SH = SC * (F * T + 1 - F);
184
+
185
+ const termL = dL / (l * SL);
186
+ const termC = dC / (c * SC);
187
+ return Math.sqrt(termL * termL + termC * termC + dH2 / (SH * SH));
188
+ }
189
+
190
+ // HyAB — Abasi & Fairchild (2020), "Distancing colour signals from the
191
+ // visual system." L distance plus euclidean (a,b) distance; performs
192
+ // better than ΔE2000 for large color differences.
193
+ export function deltaEHyAB(a, b) {
194
+ const [L1, a1, b1] = lab(toSwatch(a));
195
+ const [L2, a2, b2] = lab(toSwatch(b));
196
+ return Math.abs(L1 - L2) + Math.hypot(a1 - a2, b1 - b2);
197
+ }
198
+
199
+ export function deltaE(a, b, mode = "2000", opts) {
200
+ if (mode === "76") return deltaE76(a, b);
201
+ if (mode === "94") return deltaE94(a, b);
202
+ if (mode === "2000") return deltaE2000(a, b);
203
+ if (mode === "cmc") return deltaECMC(a, b, opts);
204
+ if (mode === "hyab") return deltaEHyAB(a, b);
205
+ if (mode === "ok") return deltaEOK(a, b);
206
+ throw new Error(`deltaE: unknown mode "${mode}"`);
207
+ }