@trishchuk/coolors-mcp 1.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 (197) hide show
  1. package/.claude/settings.local.json +39 -0
  2. package/.env +2 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.md +73 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.md +71 -0
  5. package/.github/pull_request_template.md +97 -0
  6. package/.github/workflows/ci.yml +127 -0
  7. package/.github/workflows/deploy-docs.yml +56 -0
  8. package/.github/workflows/release.yml +99 -0
  9. package/.mcp.json +12 -0
  10. package/.prettierignore +1 -0
  11. package/CLAUDE.md +201 -0
  12. package/DOCUMENTATION.md +274 -0
  13. package/GEMINI.md +54 -0
  14. package/LICENSE +21 -0
  15. package/README.md +401 -0
  16. package/demo/content_based_color.png +0 -0
  17. package/demo/music-player.html +621 -0
  18. package/demo/podcast-player.html +903 -0
  19. package/dist/bin/coolors-mcp.d.ts +1 -0
  20. package/dist/bin/coolors-mcp.js +154 -0
  21. package/dist/bin/coolors-mcp.js.map +1 -0
  22. package/dist/bin/server.d.ts +1 -0
  23. package/dist/bin/server.js +3292 -0
  24. package/dist/bin/server.js.map +1 -0
  25. package/dist/chunk-IQ7NN26V.js +114 -0
  26. package/dist/chunk-IQ7NN26V.js.map +1 -0
  27. package/dist/chunk-P3ARRKLS.js +1214 -0
  28. package/dist/chunk-P3ARRKLS.js.map +1 -0
  29. package/dist/color/index.d.ts +716 -0
  30. package/dist/color/index.js +153 -0
  31. package/dist/color/index.js.map +1 -0
  32. package/dist/coolors-mcp.d.ts +136 -0
  33. package/dist/coolors-mcp.js +7 -0
  34. package/dist/coolors-mcp.js.map +1 -0
  35. package/docs/.vitepress/cache/deps/@braintree_sanitize-url.js +93 -0
  36. package/docs/.vitepress/cache/deps/@braintree_sanitize-url.js.map +7 -0
  37. package/docs/.vitepress/cache/deps/_metadata.json +127 -0
  38. package/docs/.vitepress/cache/deps/chunk-BUSYA2B4.js +9 -0
  39. package/docs/.vitepress/cache/deps/chunk-BUSYA2B4.js.map +7 -0
  40. package/docs/.vitepress/cache/deps/chunk-JD3CXNQ6.js +12683 -0
  41. package/docs/.vitepress/cache/deps/chunk-JD3CXNQ6.js.map +7 -0
  42. package/docs/.vitepress/cache/deps/chunk-SYPOPCWC.js +9719 -0
  43. package/docs/.vitepress/cache/deps/chunk-SYPOPCWC.js.map +7 -0
  44. package/docs/.vitepress/cache/deps/cytoscape-cose-bilkent.js +4710 -0
  45. package/docs/.vitepress/cache/deps/cytoscape-cose-bilkent.js.map +7 -0
  46. package/docs/.vitepress/cache/deps/cytoscape.js +30278 -0
  47. package/docs/.vitepress/cache/deps/cytoscape.js.map +7 -0
  48. package/docs/.vitepress/cache/deps/dayjs.js +285 -0
  49. package/docs/.vitepress/cache/deps/dayjs.js.map +7 -0
  50. package/docs/.vitepress/cache/deps/debug.js +468 -0
  51. package/docs/.vitepress/cache/deps/debug.js.map +7 -0
  52. package/docs/.vitepress/cache/deps/package.json +3 -0
  53. package/docs/.vitepress/cache/deps/prismjs.js +1466 -0
  54. package/docs/.vitepress/cache/deps/prismjs.js.map +7 -0
  55. package/docs/.vitepress/cache/deps/prismjs_components_prism-bash.js +228 -0
  56. package/docs/.vitepress/cache/deps/prismjs_components_prism-bash.js.map +7 -0
  57. package/docs/.vitepress/cache/deps/prismjs_components_prism-javascript.js +142 -0
  58. package/docs/.vitepress/cache/deps/prismjs_components_prism-javascript.js.map +7 -0
  59. package/docs/.vitepress/cache/deps/prismjs_components_prism-json.js +27 -0
  60. package/docs/.vitepress/cache/deps/prismjs_components_prism-json.js.map +7 -0
  61. package/docs/.vitepress/cache/deps/prismjs_components_prism-python.js +65 -0
  62. package/docs/.vitepress/cache/deps/prismjs_components_prism-python.js.map +7 -0
  63. package/docs/.vitepress/cache/deps/prismjs_components_prism-typescript.js +53 -0
  64. package/docs/.vitepress/cache/deps/prismjs_components_prism-typescript.js.map +7 -0
  65. package/docs/.vitepress/cache/deps/prismjs_components_prism-yaml.js +73 -0
  66. package/docs/.vitepress/cache/deps/prismjs_components_prism-yaml.js.map +7 -0
  67. package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js +4507 -0
  68. package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js.map +7 -0
  69. package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js +584 -0
  70. package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js.map +7 -0
  71. package/docs/.vitepress/cache/deps/vitepress___@vueuse_integrations_useFocusTrap.js +1146 -0
  72. package/docs/.vitepress/cache/deps/vitepress___@vueuse_integrations_useFocusTrap.js.map +7 -0
  73. package/docs/.vitepress/cache/deps/vitepress___mark__js_src_vanilla__js.js +1667 -0
  74. package/docs/.vitepress/cache/deps/vitepress___mark__js_src_vanilla__js.js.map +7 -0
  75. package/docs/.vitepress/cache/deps/vitepress___minisearch.js +1814 -0
  76. package/docs/.vitepress/cache/deps/vitepress___minisearch.js.map +7 -0
  77. package/docs/.vitepress/cache/deps/vue.js +344 -0
  78. package/docs/.vitepress/cache/deps/vue.js.map +7 -0
  79. package/docs/.vitepress/components/ClientGrid.vue +125 -0
  80. package/docs/.vitepress/components/CodeBlock.vue +231 -0
  81. package/docs/.vitepress/components/ConfigModal.vue +477 -0
  82. package/docs/.vitepress/components/DiagramModal.vue +528 -0
  83. package/docs/.vitepress/components/TroubleshootingModal.vue +472 -0
  84. package/docs/.vitepress/config.js +162 -0
  85. package/docs/.vitepress/theme/FundingLayout.vue +251 -0
  86. package/docs/.vitepress/theme/Layout.vue +134 -0
  87. package/docs/.vitepress/theme/components/AdBanner.vue +317 -0
  88. package/docs/.vitepress/theme/components/AdPlaceholder.vue +78 -0
  89. package/docs/.vitepress/theme/components/FundingEffects.vue +322 -0
  90. package/docs/.vitepress/theme/components/FundingHero.vue +345 -0
  91. package/docs/.vitepress/theme/components/SupportSection.vue +511 -0
  92. package/docs/.vitepress/theme/custom-app.css +339 -0
  93. package/docs/.vitepress/theme/custom.css +699 -0
  94. package/docs/.vitepress/theme/index.js +25 -0
  95. package/docs/README.md +198 -0
  96. package/docs/concepts/accessibility.md +473 -0
  97. package/docs/concepts/color-spaces.md +222 -0
  98. package/docs/concepts/distance-metrics.md +384 -0
  99. package/docs/concepts/hct.md +261 -0
  100. package/docs/concepts/image-analysis.md +396 -0
  101. package/docs/concepts/material-design.md +306 -0
  102. package/docs/concepts/theme-matching.md +399 -0
  103. package/docs/examples/basic-colors.md +490 -0
  104. package/docs/examples/creating-themes.md +898 -0
  105. package/docs/examples/css-refactoring.md +824 -0
  106. package/docs/examples/image-extraction.md +882 -0
  107. package/docs/getting-started.md +366 -0
  108. package/docs/index.md +190 -0
  109. package/docs/installation.md +157 -0
  110. package/docs/tools/README.md +234 -0
  111. package/docs/tools/accessibility.md +614 -0
  112. package/docs/tools/color-operations.md +374 -0
  113. package/docs/tools/image-extraction.md +624 -0
  114. package/docs/tools/material-design.md +347 -0
  115. package/docs/tools/theme-matching.md +552 -0
  116. package/eslint.config.ts +14 -0
  117. package/examples/theme-matching.md +113 -0
  118. package/jsr.json +7 -0
  119. package/mcp-config.json +8 -0
  120. package/note.md +35 -0
  121. package/package.json +122 -0
  122. package/research_results.md +53 -0
  123. package/src/bin/coolors-mcp.ts +194 -0
  124. package/src/bin/server.ts +61 -0
  125. package/src/color/__tests__/conversions-argb.test.ts +198 -0
  126. package/src/color/__tests__/extract-colors.test.ts +360 -0
  127. package/src/color/__tests__/image-utils.test.ts +242 -0
  128. package/src/color/__tests__/reference-colors.test.ts +278 -0
  129. package/src/color/__tests__/round-trip.test.ts +197 -0
  130. package/src/color/conversions.test.ts +402 -0
  131. package/src/color/conversions.ts +393 -0
  132. package/src/color/dislike/__tests__/dislike-analyzer.test.ts +248 -0
  133. package/src/color/dislike/dislike-analyzer.ts +114 -0
  134. package/src/color/extract-colors.ts +228 -0
  135. package/src/color/hct/__tests__/hct-class.test.ts +232 -0
  136. package/src/color/hct/harmonization.ts +204 -0
  137. package/src/color/hct/hct-class.ts +109 -0
  138. package/src/color/hct/hct-solver.ts +168 -0
  139. package/src/color/hct/index.ts +39 -0
  140. package/src/color/hct/tonal-palette.ts +211 -0
  141. package/src/color/hct/types.ts +88 -0
  142. package/src/color/image-utils.ts +79 -0
  143. package/src/color/index.ts +87 -0
  144. package/src/color/material-theme.ts +157 -0
  145. package/src/color/metrics.test.ts +276 -0
  146. package/src/color/metrics.ts +281 -0
  147. package/src/color/quantize/__tests__/quantizer_celebi.test.ts +195 -0
  148. package/src/color/quantize/lab_point_provider.ts +55 -0
  149. package/src/color/quantize/point_provider.ts +27 -0
  150. package/src/color/quantize/quantizer_celebi.ts +51 -0
  151. package/src/color/quantize/quantizer_celebi_test.ts +71 -0
  152. package/src/color/quantize/quantizer_map.ts +47 -0
  153. package/src/color/quantize/quantizer_wsmeans.ts +232 -0
  154. package/src/color/quantize/quantizer_wu.ts +472 -0
  155. package/src/color/score/__tests__/score.test.ts +224 -0
  156. package/src/color/score/score.ts +175 -0
  157. package/src/color/types.ts +151 -0
  158. package/src/color/utils/color_utils.ts +292 -0
  159. package/src/color/utils/math_utils.ts +145 -0
  160. package/src/color/utils.test.ts +403 -0
  161. package/src/color/utils.ts +315 -0
  162. package/src/constants.ts +5 -0
  163. package/src/coolors-mcp.ts +37 -0
  164. package/src/examples/addition.ts +333 -0
  165. package/src/examples/color-demo.ts +125 -0
  166. package/src/examples/custom-logger.ts +201 -0
  167. package/src/examples/oauth-server.ts +113 -0
  168. package/src/examples/session-context.ts +269 -0
  169. package/src/session.ts +116 -0
  170. package/src/theme/__tests__/matcher.test.ts +180 -0
  171. package/src/theme/__tests__/parser.test.ts +148 -0
  172. package/src/theme/__tests__/refactor.test.ts +224 -0
  173. package/src/theme/index.ts +34 -0
  174. package/src/theme/matcher.ts +395 -0
  175. package/src/theme/parser.ts +392 -0
  176. package/src/theme/refactor.ts +360 -0
  177. package/src/theme/types.ts +152 -0
  178. package/src/tools/__tests__/gradient-generator.test.ts +206 -0
  179. package/src/tools/__tests__/palette-with-locks.test.ts +109 -0
  180. package/src/tools/color-conversion.tool.ts +54 -0
  181. package/src/tools/color-distance.tool.ts +41 -0
  182. package/src/tools/colors.ts +31 -0
  183. package/src/tools/contrast-checker.tool.ts +37 -0
  184. package/src/tools/dislike-analyzer.tool.ts +247 -0
  185. package/src/tools/gradient-generator.tool.ts +250 -0
  186. package/src/tools/image-extraction.tools.ts +289 -0
  187. package/src/tools/index.ts +39 -0
  188. package/src/tools/material-theme.tools.ts +250 -0
  189. package/src/tools/palette-generator.tool.ts +135 -0
  190. package/src/tools/palette-with-locks.tool.ts +221 -0
  191. package/src/tools/registry.ts +142 -0
  192. package/src/tools/simple-tools.ts +37 -0
  193. package/src/tools/theme-matching.tools.ts +334 -0
  194. package/src/types.ts +182 -0
  195. package/src/utils.ts +22 -0
  196. package/tsconfig.json +8 -0
  197. package/vitest.config.js +15 -0
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Tests for color distance metrics
3
+ */
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import type { LAB, RGB } from "./types";
8
+
9
+ import { rgbToLab } from "./conversions";
10
+ import {
11
+ areColorsSimilar,
12
+ colorDistance,
13
+ deltaE2000,
14
+ deltaE76,
15
+ deltaE94,
16
+ euclideanDistance,
17
+ findMostDifferentColor,
18
+ findMostSimilarColor,
19
+ weightedRgbDistance,
20
+ } from "./metrics";
21
+
22
+ describe("Color Metrics", () => {
23
+ describe("euclideanDistance", () => {
24
+ it("should calculate Euclidean distance correctly", () => {
25
+ const black: RGB = { b: 0, g: 0, r: 0 };
26
+ const white: RGB = { b: 255, g: 255, r: 255 };
27
+ const red: RGB = { b: 0, g: 0, r: 255 };
28
+ const green: RGB = { b: 0, g: 255, r: 0 };
29
+
30
+ // Same color should have distance 0
31
+ expect(euclideanDistance(black, black)).toBe(0);
32
+ expect(euclideanDistance(white, white)).toBe(0);
33
+
34
+ // Black to white - maximum distance in RGB cube
35
+ expect(euclideanDistance(black, white)).toBeCloseTo(441.67, 2);
36
+
37
+ // Red to green
38
+ expect(euclideanDistance(red, green)).toBeCloseTo(360.62, 2);
39
+ });
40
+ });
41
+
42
+ describe("weightedRgbDistance", () => {
43
+ it("should calculate weighted RGB distance with default weights", () => {
44
+ const black: RGB = { b: 0, g: 0, r: 0 };
45
+ const white: RGB = { b: 255, g: 255, r: 255 };
46
+
47
+ const distance = weightedRgbDistance(black, white);
48
+ expect(distance).toBeGreaterThan(0);
49
+ expect(distance).toBeLessThan(euclideanDistance(black, white));
50
+ });
51
+
52
+ it("should use custom weights", () => {
53
+ const color1: RGB = { b: 100, g: 100, r: 100 };
54
+ const color2: RGB = { b: 150, g: 150, r: 150 };
55
+
56
+ const defaultDistance = weightedRgbDistance(color1, color2);
57
+ const customDistance = weightedRgbDistance(color1, color2, {
58
+ b: 0,
59
+ g: 0,
60
+ r: 1,
61
+ });
62
+
63
+ expect(customDistance).toBe(50); // Only red channel difference
64
+ expect(defaultDistance).not.toBe(customDistance);
65
+ });
66
+ });
67
+
68
+ describe("deltaE76", () => {
69
+ it("should calculate Delta E 76 correctly", () => {
70
+ const lab1: LAB = { a: 0, b: 0, l: 50 };
71
+ const lab2: LAB = { a: 0, b: 0, l: 50 };
72
+
73
+ // Same color
74
+ expect(deltaE76(lab1, lab2)).toBe(0);
75
+
76
+ // Different colors
77
+ const lab3: LAB = { a: 10, b: 10, l: 60 };
78
+ const distance = deltaE76(lab1, lab3);
79
+ expect(distance).toBeCloseTo(17.32, 2);
80
+ });
81
+
82
+ it("should work with actual colors", () => {
83
+ const red = rgbToLab({ b: 0, g: 0, r: 255 });
84
+ const green = rgbToLab({ b: 0, g: 255, r: 0 });
85
+ const blue = rgbToLab({ b: 255, g: 0, r: 0 });
86
+
87
+ const redGreenDistance = deltaE76(red, green);
88
+ const redBlueDistance = deltaE76(red, blue);
89
+ const greenBlueDistance = deltaE76(green, blue);
90
+
91
+ // All primary colors should be quite different
92
+ expect(redGreenDistance).toBeGreaterThan(50);
93
+ expect(redBlueDistance).toBeGreaterThan(50);
94
+ expect(greenBlueDistance).toBeGreaterThan(50);
95
+ });
96
+ });
97
+
98
+ describe("deltaE94", () => {
99
+ it("should calculate Delta E 94 correctly", () => {
100
+ const lab1: LAB = { a: 0, b: 0, l: 50 };
101
+ const lab2: LAB = { a: 0, b: 0, l: 50 };
102
+
103
+ // Same color
104
+ expect(deltaE94(lab1, lab2)).toBe(0);
105
+
106
+ // Different colors
107
+ const lab3: LAB = { a: 10, b: 10, l: 60 };
108
+ const distance = deltaE94(lab1, lab3);
109
+ expect(distance).toBeGreaterThan(0);
110
+ expect(distance).toBeLessThanOrEqual(deltaE76(lab1, lab3)); // DE94 may be equal or less than DE76
111
+ });
112
+
113
+ it("should use custom parameters", () => {
114
+ const lab1: LAB = { a: 20, b: 20, l: 50 };
115
+ const lab2: LAB = { a: 25, b: 25, l: 60 };
116
+
117
+ const defaultDistance = deltaE94(lab1, lab2);
118
+ const customDistance = deltaE94(lab1, lab2, { kC: 1, kH: 1, kL: 2 });
119
+
120
+ expect(customDistance).not.toBe(defaultDistance);
121
+ });
122
+ });
123
+
124
+ describe("deltaE2000", () => {
125
+ it("should calculate Delta E 2000 correctly", () => {
126
+ const lab1: LAB = { a: 0, b: 0, l: 50 };
127
+ const lab2: LAB = { a: 0, b: 0, l: 50 };
128
+
129
+ // Same color
130
+ expect(deltaE2000(lab1, lab2)).toBe(0);
131
+
132
+ // Test with known values
133
+ const lab3: LAB = { a: 2.6772, b: -79.7751, l: 50 };
134
+ const lab4: LAB = { a: 0, b: -82.7485, l: 50 };
135
+ const distance = deltaE2000(lab3, lab4);
136
+ expect(distance).toBeCloseTo(2.04, 1);
137
+ });
138
+
139
+ it("should handle colors across hue boundaries", () => {
140
+ const red = rgbToLab({ b: 0, g: 0, r: 255 });
141
+ const slightlyDifferentRed = rgbToLab({ b: 5, g: 5, r: 250 });
142
+
143
+ const distance = deltaE2000(red, slightlyDifferentRed);
144
+ expect(distance).toBeGreaterThan(0);
145
+ expect(distance).toBeLessThan(5); // Should be a small difference
146
+ });
147
+ });
148
+
149
+ describe("colorDistance", () => {
150
+ const color1: RGB = { b: 0, g: 0, r: 255 };
151
+ const color2: RGB = { b: 0, g: 255, r: 0 };
152
+
153
+ it("should default to deltaE2000", () => {
154
+ const distance1 = colorDistance(color1, color2);
155
+ const distance2 = colorDistance(color1, color2, { metric: "deltaE2000" });
156
+ expect(distance1).toBe(distance2);
157
+ });
158
+
159
+ it("should use specified metric", () => {
160
+ const euclidean = colorDistance(color1, color2, { metric: "euclidean" });
161
+ const weighted = colorDistance(color1, color2, { metric: "weighted" });
162
+ const de76 = colorDistance(color1, color2, { metric: "deltaE76" });
163
+ const de94 = colorDistance(color1, color2, { metric: "deltaE94" });
164
+ const de2000 = colorDistance(color1, color2, { metric: "deltaE2000" });
165
+
166
+ // All should be different
167
+ expect(euclidean).not.toBe(weighted);
168
+ expect(de76).not.toBe(de94);
169
+ expect(de94).not.toBe(de2000);
170
+ });
171
+ });
172
+
173
+ describe("areColorsSimilar", () => {
174
+ it("should identify similar colors", () => {
175
+ const red1: RGB = { b: 0, g: 0, r: 255 };
176
+ const red2: RGB = { b: 5, g: 5, r: 250 };
177
+ const green: RGB = { b: 0, g: 255, r: 0 };
178
+
179
+ expect(areColorsSimilar(red1, red1)).toBe(true); // Same color
180
+ expect(areColorsSimilar(red1, red2)).toBe(true); // Very similar
181
+ expect(areColorsSimilar(red1, green)).toBe(false); // Very different
182
+ });
183
+
184
+ it("should use custom threshold", () => {
185
+ const color1: RGB = { b: 100, g: 100, r: 100 };
186
+ const color2: RGB = { b: 110, g: 110, r: 110 };
187
+
188
+ // With default threshold (2.3)
189
+ expect(areColorsSimilar(color1, color2)).toBe(false);
190
+
191
+ // With larger threshold
192
+ expect(areColorsSimilar(color1, color2, 10)).toBe(true);
193
+
194
+ // With smaller threshold
195
+ expect(areColorsSimilar(color1, color2, 1)).toBe(false);
196
+ });
197
+ });
198
+
199
+ describe("findMostDifferentColor", () => {
200
+ it("should find the most different color", () => {
201
+ const baseColor: RGB = { b: 0, g: 0, r: 255 }; // Red
202
+ const colors: RGB[] = [
203
+ { b: 0, g: 0, r: 250 }, // Very similar to red
204
+ { b: 0, g: 0, r: 200 }, // Darker red
205
+ { b: 0, g: 255, r: 0 }, // Green - most different
206
+ { b: 100, g: 100, r: 255 }, // Light red
207
+ ];
208
+
209
+ const mostDifferent = findMostDifferentColor(baseColor, colors);
210
+ expect(mostDifferent).toEqual({ b: 0, g: 255, r: 0 }); // Green
211
+ });
212
+
213
+ it("should return null for empty array", () => {
214
+ const baseColor: RGB = { b: 0, g: 0, r: 255 };
215
+ expect(findMostDifferentColor(baseColor, [])).toBeNull();
216
+ });
217
+
218
+ it("should work with single color", () => {
219
+ const baseColor: RGB = { b: 0, g: 0, r: 255 };
220
+ const colors: RGB[] = [{ b: 0, g: 255, r: 0 }];
221
+
222
+ const result = findMostDifferentColor(baseColor, colors);
223
+ expect(result).toEqual({ b: 0, g: 255, r: 0 });
224
+ });
225
+ });
226
+
227
+ describe("findMostSimilarColor", () => {
228
+ it("should find the most similar color", () => {
229
+ const baseColor: RGB = { b: 0, g: 0, r: 255 }; // Red
230
+ const colors: RGB[] = [
231
+ { b: 0, g: 0, r: 250 }, // Very similar to red - most similar
232
+ { b: 0, g: 0, r: 200 }, // Darker red
233
+ { b: 0, g: 255, r: 0 }, // Green - most different
234
+ { b: 100, g: 100, r: 255 }, // Light red
235
+ ];
236
+
237
+ const mostSimilar = findMostSimilarColor(baseColor, colors);
238
+ expect(mostSimilar).toEqual({ b: 0, g: 0, r: 250 });
239
+ });
240
+
241
+ it("should return null for empty array", () => {
242
+ const baseColor: RGB = { b: 0, g: 0, r: 255 };
243
+ expect(findMostSimilarColor(baseColor, [])).toBeNull();
244
+ });
245
+
246
+ it("should work with single color", () => {
247
+ const baseColor: RGB = { b: 0, g: 0, r: 255 };
248
+ const colors: RGB[] = [{ b: 0, g: 255, r: 0 }];
249
+
250
+ const result = findMostSimilarColor(baseColor, colors);
251
+ expect(result).toEqual({ b: 0, g: 255, r: 0 });
252
+ });
253
+ });
254
+
255
+ describe("metric comparisons", () => {
256
+ it("should show different characteristics for each metric", () => {
257
+ // Colors that are perceptually similar but numerically different
258
+ const color1: RGB = { b: 50, g: 50, r: 50 };
259
+ const color2: RGB = { b: 55, g: 55, r: 55 };
260
+
261
+ const euclidean = colorDistance(color1, color2, { metric: "euclidean" });
262
+ const weighted = colorDistance(color1, color2, { metric: "weighted" });
263
+ const de76 = colorDistance(color1, color2, { metric: "deltaE76" });
264
+ const de94 = colorDistance(color1, color2, { metric: "deltaE94" });
265
+ const de2000 = colorDistance(color1, color2, { metric: "deltaE2000" });
266
+
267
+ // Euclidean should be largest (not perceptually uniform)
268
+ expect(euclidean).toBeGreaterThan(weighted);
269
+
270
+ // Delta E formulas should give progressively better perceptual uniformity
271
+ expect(de76).toBeGreaterThan(0);
272
+ expect(de94).toBeGreaterThan(0);
273
+ expect(de2000).toBeGreaterThan(0);
274
+ });
275
+ });
276
+ });
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Color distance metrics for calculating perceptual differences between colors
3
+ */
4
+
5
+ import type { ColorDistanceOptions, LAB, RGB } from "./types.js";
6
+
7
+ import { rgbToLab } from "./conversions.js";
8
+
9
+ /**
10
+ * Determine if two colors are perceptually similar
11
+ * Uses Delta E 2000 with a threshold
12
+ */
13
+ export function areColorsSimilar(
14
+ color1: RGB,
15
+ color2: RGB,
16
+ threshold: number = 2.3, // JND (Just Noticeable Difference)
17
+ ): boolean {
18
+ const distance = colorDistance(color1, color2, { metric: "deltaE2000" });
19
+ return distance <= threshold;
20
+ }
21
+
22
+ /**
23
+ * Calculate color distance between two RGB colors using specified metric
24
+ */
25
+ export function colorDistance(
26
+ color1: RGB,
27
+ color2: RGB,
28
+ options?: ColorDistanceOptions,
29
+ ): number {
30
+ const metric = options?.metric ?? "deltaE2000";
31
+
32
+ switch (metric) {
33
+ case "deltaE76": {
34
+ const lab1 = rgbToLab(color1);
35
+ const lab2 = rgbToLab(color2);
36
+ return deltaE76(lab1, lab2);
37
+ }
38
+
39
+ case "deltaE94": {
40
+ const lab1 = rgbToLab(color1);
41
+ const lab2 = rgbToLab(color2);
42
+ return deltaE94(lab1, lab2, options?.deltaE94);
43
+ }
44
+
45
+ case "euclidean":
46
+ return euclideanDistance(color1, color2);
47
+
48
+ case "weighted":
49
+ return weightedRgbDistance(color1, color2, options?.weights);
50
+
51
+ case "deltaE2000":
52
+ default: {
53
+ const lab1 = rgbToLab(color1);
54
+ const lab2 = rgbToLab(color2);
55
+ return deltaE2000(lab1, lab2);
56
+ }
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Calculate Delta E CIE2000 (ΔE*00)
62
+ * Most accurate perceptual color difference formula
63
+ */
64
+ export function deltaE2000(lab1: LAB, lab2: LAB): number {
65
+ const deg2rad = (deg: number) => deg * (Math.PI / 180);
66
+ const rad2deg = (rad: number) => rad * (180 / Math.PI);
67
+
68
+ // Weight factors
69
+ const kL = 1;
70
+ const kC = 1;
71
+ const kH = 1;
72
+
73
+ // Calculate C' and h'
74
+ const C1 = Math.sqrt(lab1.a * lab1.a + lab1.b * lab1.b);
75
+ const C2 = Math.sqrt(lab2.a * lab2.a + lab2.b * lab2.b);
76
+ const Cbar = (C1 + C2) / 2;
77
+
78
+ const G =
79
+ 0.5 *
80
+ (1 - Math.sqrt(Math.pow(Cbar, 7) / (Math.pow(Cbar, 7) + Math.pow(25, 7))));
81
+
82
+ const a1p = lab1.a * (1 + G);
83
+ const a2p = lab2.a * (1 + G);
84
+
85
+ const C1p = Math.sqrt(a1p * a1p + lab1.b * lab1.b);
86
+ const C2p = Math.sqrt(a2p * a2p + lab2.b * lab2.b);
87
+
88
+ let h1p = Math.atan2(lab1.b, a1p);
89
+ let h2p = Math.atan2(lab2.b, a2p);
90
+
91
+ if (h1p < 0) h1p += 2 * Math.PI;
92
+ if (h2p < 0) h2p += 2 * Math.PI;
93
+
94
+ h1p = rad2deg(h1p);
95
+ h2p = rad2deg(h2p);
96
+
97
+ // Calculate deltas
98
+ const dLp = lab2.l - lab1.l;
99
+ const dCp = C2p - C1p;
100
+
101
+ let dhp = h2p - h1p;
102
+ if (dhp > 180) dhp -= 360;
103
+ if (dhp < -180) dhp += 360;
104
+
105
+ const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin(deg2rad(dhp) / 2);
106
+
107
+ // Calculate averages
108
+ const Lbar = (lab1.l + lab2.l) / 2;
109
+ const Cpbar = (C1p + C2p) / 2;
110
+
111
+ let hpbar = (h1p + h2p) / 2;
112
+ if (Math.abs(h1p - h2p) > 180) {
113
+ if (h1p + h2p < 360) {
114
+ hpbar += 180;
115
+ } else {
116
+ hpbar -= 180;
117
+ }
118
+ }
119
+
120
+ // Calculate T
121
+ const T =
122
+ 1 -
123
+ 0.17 * Math.cos(deg2rad(hpbar - 30)) +
124
+ 0.24 * Math.cos(deg2rad(2 * hpbar)) +
125
+ 0.32 * Math.cos(deg2rad(3 * hpbar + 6)) -
126
+ 0.2 * Math.cos(deg2rad(4 * hpbar - 63));
127
+
128
+ // Calculate rotation term
129
+ const dTheta = 30 * Math.exp(-Math.pow((hpbar - 275) / 25, 2));
130
+ const RC =
131
+ 2 * Math.sqrt(Math.pow(Cpbar, 7) / (Math.pow(Cpbar, 7) + Math.pow(25, 7)));
132
+ const RT = -RC * Math.sin(2 * deg2rad(dTheta));
133
+
134
+ // Calculate weighting functions
135
+ const SL =
136
+ 1 +
137
+ (0.015 * Math.pow(Lbar - 50, 2)) / Math.sqrt(20 + Math.pow(Lbar - 50, 2));
138
+ const SC = 1 + 0.045 * Cpbar;
139
+ const SH = 1 + 0.015 * Cpbar * T;
140
+
141
+ // Calculate final Delta E 2000
142
+ const dLpKlSl = dLp / (kL * SL);
143
+ const dCpKcSc = dCp / (kC * SC);
144
+ const dHpKhSh = dHp / (kH * SH);
145
+
146
+ return Math.sqrt(
147
+ dLpKlSl * dLpKlSl +
148
+ dCpKcSc * dCpKcSc +
149
+ dHpKhSh * dHpKhSh +
150
+ RT * dCpKcSc * dHpKhSh,
151
+ );
152
+ }
153
+
154
+ /**
155
+ * Calculate Delta E CIE76 (ΔE*ab)
156
+ * Original CIE color difference formula
157
+ */
158
+ export function deltaE76(lab1: LAB, lab2: LAB): number {
159
+ const dL = lab1.l - lab2.l;
160
+ const da = lab1.a - lab2.a;
161
+ const db = lab1.b - lab2.b;
162
+
163
+ return Math.sqrt(dL * dL + da * da + db * db);
164
+ }
165
+
166
+ /**
167
+ * Calculate Delta E CIE94 (ΔE*94)
168
+ * Improved perceptual uniformity over CIE76
169
+ */
170
+ export function deltaE94(
171
+ lab1: LAB,
172
+ lab2: LAB,
173
+ options?: { kC?: number; kH?: number; kL?: number },
174
+ ): number {
175
+ // Default values for graphic arts
176
+ const kL = options?.kL ?? 1;
177
+ const kC = options?.kC ?? 1;
178
+ const kH = options?.kH ?? 1;
179
+
180
+ const dL = lab1.l - lab2.l;
181
+ const da = lab1.a - lab2.a;
182
+ const db = lab1.b - lab2.b;
183
+
184
+ const C1 = Math.sqrt(lab1.a * lab1.a + lab1.b * lab1.b);
185
+ const C2 = Math.sqrt(lab2.a * lab2.a + lab2.b * lab2.b);
186
+ const dC = C1 - C2;
187
+
188
+ const dH2 = da * da + db * db - dC * dC;
189
+ const dH = dH2 > 0 ? Math.sqrt(dH2) : 0;
190
+
191
+ const SL = 1;
192
+ const SC = 1 + 0.045 * C1;
193
+ const SH = 1 + 0.015 * C1;
194
+
195
+ const dLKlSl = dL / (kL * SL);
196
+ const dCKcSc = dC / (kC * SC);
197
+ const dHKhSh = dH / (kH * SH);
198
+
199
+ return Math.sqrt(dLKlSl * dLKlSl + dCKcSc * dCKcSc + dHKhSh * dHKhSh);
200
+ }
201
+
202
+ /**
203
+ * Calculate Euclidean distance between two RGB colors
204
+ * Simple but not perceptually uniform
205
+ */
206
+ export function euclideanDistance(color1: RGB, color2: RGB): number {
207
+ const dr = color1.r - color2.r;
208
+ const dg = color1.g - color2.g;
209
+ const db = color1.b - color2.b;
210
+
211
+ return Math.sqrt(dr * dr + dg * dg + db * db);
212
+ }
213
+
214
+ /**
215
+ * Find the most different color from a set of colors
216
+ */
217
+ export function findMostDifferentColor(
218
+ baseColor: RGB,
219
+ colors: RGB[],
220
+ options?: ColorDistanceOptions,
221
+ ): null | RGB {
222
+ if (colors.length === 0) return null;
223
+
224
+ let maxDistance = -Infinity;
225
+ let mostDifferent = colors[0];
226
+
227
+ for (const color of colors) {
228
+ const distance = colorDistance(baseColor, color, options);
229
+ if (distance > maxDistance) {
230
+ maxDistance = distance;
231
+ mostDifferent = color;
232
+ }
233
+ }
234
+
235
+ return mostDifferent;
236
+ }
237
+
238
+ /**
239
+ * Find the most similar color from a set of colors
240
+ */
241
+ export function findMostSimilarColor(
242
+ baseColor: RGB,
243
+ colors: RGB[],
244
+ options?: ColorDistanceOptions,
245
+ ): null | RGB {
246
+ if (colors.length === 0) return null;
247
+
248
+ let minDistance = Infinity;
249
+ let mostSimilar = colors[0];
250
+
251
+ for (const color of colors) {
252
+ const distance = colorDistance(baseColor, color, options);
253
+ if (distance < minDistance) {
254
+ minDistance = distance;
255
+ mostSimilar = color;
256
+ }
257
+ }
258
+
259
+ return mostSimilar;
260
+ }
261
+
262
+ /**
263
+ * Calculate weighted RGB distance
264
+ * Better approximation of perceptual difference than simple Euclidean
265
+ */
266
+ export function weightedRgbDistance(
267
+ color1: RGB,
268
+ color2: RGB,
269
+ weights?: { b?: number; g?: number; r?: number },
270
+ ): number {
271
+ // Default weights based on human eye sensitivity
272
+ const wr = weights?.r ?? 0.3;
273
+ const wg = weights?.g ?? 0.59;
274
+ const wb = weights?.b ?? 0.11;
275
+
276
+ const dr = (color1.r - color2.r) * wr;
277
+ const dg = (color1.g - color2.g) * wg;
278
+ const db = (color1.b - color2.b) * wb;
279
+
280
+ return Math.sqrt(dr * dr + dg * dg + db * db);
281
+ }