@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.
- package/.claude/settings.local.json +39 -0
- package/.env +2 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +73 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +71 -0
- package/.github/pull_request_template.md +97 -0
- package/.github/workflows/ci.yml +127 -0
- package/.github/workflows/deploy-docs.yml +56 -0
- package/.github/workflows/release.yml +99 -0
- package/.mcp.json +12 -0
- package/.prettierignore +1 -0
- package/CLAUDE.md +201 -0
- package/DOCUMENTATION.md +274 -0
- package/GEMINI.md +54 -0
- package/LICENSE +21 -0
- package/README.md +401 -0
- package/demo/content_based_color.png +0 -0
- package/demo/music-player.html +621 -0
- package/demo/podcast-player.html +903 -0
- package/dist/bin/coolors-mcp.d.ts +1 -0
- package/dist/bin/coolors-mcp.js +154 -0
- package/dist/bin/coolors-mcp.js.map +1 -0
- package/dist/bin/server.d.ts +1 -0
- package/dist/bin/server.js +3292 -0
- package/dist/bin/server.js.map +1 -0
- package/dist/chunk-IQ7NN26V.js +114 -0
- package/dist/chunk-IQ7NN26V.js.map +1 -0
- package/dist/chunk-P3ARRKLS.js +1214 -0
- package/dist/chunk-P3ARRKLS.js.map +1 -0
- package/dist/color/index.d.ts +716 -0
- package/dist/color/index.js +153 -0
- package/dist/color/index.js.map +1 -0
- package/dist/coolors-mcp.d.ts +136 -0
- package/dist/coolors-mcp.js +7 -0
- package/dist/coolors-mcp.js.map +1 -0
- package/docs/.vitepress/cache/deps/@braintree_sanitize-url.js +93 -0
- package/docs/.vitepress/cache/deps/@braintree_sanitize-url.js.map +7 -0
- package/docs/.vitepress/cache/deps/_metadata.json +127 -0
- package/docs/.vitepress/cache/deps/chunk-BUSYA2B4.js +9 -0
- package/docs/.vitepress/cache/deps/chunk-BUSYA2B4.js.map +7 -0
- package/docs/.vitepress/cache/deps/chunk-JD3CXNQ6.js +12683 -0
- package/docs/.vitepress/cache/deps/chunk-JD3CXNQ6.js.map +7 -0
- package/docs/.vitepress/cache/deps/chunk-SYPOPCWC.js +9719 -0
- package/docs/.vitepress/cache/deps/chunk-SYPOPCWC.js.map +7 -0
- package/docs/.vitepress/cache/deps/cytoscape-cose-bilkent.js +4710 -0
- package/docs/.vitepress/cache/deps/cytoscape-cose-bilkent.js.map +7 -0
- package/docs/.vitepress/cache/deps/cytoscape.js +30278 -0
- package/docs/.vitepress/cache/deps/cytoscape.js.map +7 -0
- package/docs/.vitepress/cache/deps/dayjs.js +285 -0
- package/docs/.vitepress/cache/deps/dayjs.js.map +7 -0
- package/docs/.vitepress/cache/deps/debug.js +468 -0
- package/docs/.vitepress/cache/deps/debug.js.map +7 -0
- package/docs/.vitepress/cache/deps/package.json +3 -0
- package/docs/.vitepress/cache/deps/prismjs.js +1466 -0
- package/docs/.vitepress/cache/deps/prismjs.js.map +7 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-bash.js +228 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-bash.js.map +7 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-javascript.js +142 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-javascript.js.map +7 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-json.js +27 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-json.js.map +7 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-python.js +65 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-python.js.map +7 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-typescript.js +53 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-typescript.js.map +7 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-yaml.js +73 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-yaml.js.map +7 -0
- package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js +4507 -0
- package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js.map +7 -0
- package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js +584 -0
- package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js.map +7 -0
- package/docs/.vitepress/cache/deps/vitepress___@vueuse_integrations_useFocusTrap.js +1146 -0
- package/docs/.vitepress/cache/deps/vitepress___@vueuse_integrations_useFocusTrap.js.map +7 -0
- package/docs/.vitepress/cache/deps/vitepress___mark__js_src_vanilla__js.js +1667 -0
- package/docs/.vitepress/cache/deps/vitepress___mark__js_src_vanilla__js.js.map +7 -0
- package/docs/.vitepress/cache/deps/vitepress___minisearch.js +1814 -0
- package/docs/.vitepress/cache/deps/vitepress___minisearch.js.map +7 -0
- package/docs/.vitepress/cache/deps/vue.js +344 -0
- package/docs/.vitepress/cache/deps/vue.js.map +7 -0
- package/docs/.vitepress/components/ClientGrid.vue +125 -0
- package/docs/.vitepress/components/CodeBlock.vue +231 -0
- package/docs/.vitepress/components/ConfigModal.vue +477 -0
- package/docs/.vitepress/components/DiagramModal.vue +528 -0
- package/docs/.vitepress/components/TroubleshootingModal.vue +472 -0
- package/docs/.vitepress/config.js +162 -0
- package/docs/.vitepress/theme/FundingLayout.vue +251 -0
- package/docs/.vitepress/theme/Layout.vue +134 -0
- package/docs/.vitepress/theme/components/AdBanner.vue +317 -0
- package/docs/.vitepress/theme/components/AdPlaceholder.vue +78 -0
- package/docs/.vitepress/theme/components/FundingEffects.vue +322 -0
- package/docs/.vitepress/theme/components/FundingHero.vue +345 -0
- package/docs/.vitepress/theme/components/SupportSection.vue +511 -0
- package/docs/.vitepress/theme/custom-app.css +339 -0
- package/docs/.vitepress/theme/custom.css +699 -0
- package/docs/.vitepress/theme/index.js +25 -0
- package/docs/README.md +198 -0
- package/docs/concepts/accessibility.md +473 -0
- package/docs/concepts/color-spaces.md +222 -0
- package/docs/concepts/distance-metrics.md +384 -0
- package/docs/concepts/hct.md +261 -0
- package/docs/concepts/image-analysis.md +396 -0
- package/docs/concepts/material-design.md +306 -0
- package/docs/concepts/theme-matching.md +399 -0
- package/docs/examples/basic-colors.md +490 -0
- package/docs/examples/creating-themes.md +898 -0
- package/docs/examples/css-refactoring.md +824 -0
- package/docs/examples/image-extraction.md +882 -0
- package/docs/getting-started.md +366 -0
- package/docs/index.md +190 -0
- package/docs/installation.md +157 -0
- package/docs/tools/README.md +234 -0
- package/docs/tools/accessibility.md +614 -0
- package/docs/tools/color-operations.md +374 -0
- package/docs/tools/image-extraction.md +624 -0
- package/docs/tools/material-design.md +347 -0
- package/docs/tools/theme-matching.md +552 -0
- package/eslint.config.ts +14 -0
- package/examples/theme-matching.md +113 -0
- package/jsr.json +7 -0
- package/mcp-config.json +8 -0
- package/note.md +35 -0
- package/package.json +122 -0
- package/research_results.md +53 -0
- package/src/bin/coolors-mcp.ts +194 -0
- package/src/bin/server.ts +61 -0
- package/src/color/__tests__/conversions-argb.test.ts +198 -0
- package/src/color/__tests__/extract-colors.test.ts +360 -0
- package/src/color/__tests__/image-utils.test.ts +242 -0
- package/src/color/__tests__/reference-colors.test.ts +278 -0
- package/src/color/__tests__/round-trip.test.ts +197 -0
- package/src/color/conversions.test.ts +402 -0
- package/src/color/conversions.ts +393 -0
- package/src/color/dislike/__tests__/dislike-analyzer.test.ts +248 -0
- package/src/color/dislike/dislike-analyzer.ts +114 -0
- package/src/color/extract-colors.ts +228 -0
- package/src/color/hct/__tests__/hct-class.test.ts +232 -0
- package/src/color/hct/harmonization.ts +204 -0
- package/src/color/hct/hct-class.ts +109 -0
- package/src/color/hct/hct-solver.ts +168 -0
- package/src/color/hct/index.ts +39 -0
- package/src/color/hct/tonal-palette.ts +211 -0
- package/src/color/hct/types.ts +88 -0
- package/src/color/image-utils.ts +79 -0
- package/src/color/index.ts +87 -0
- package/src/color/material-theme.ts +157 -0
- package/src/color/metrics.test.ts +276 -0
- package/src/color/metrics.ts +281 -0
- package/src/color/quantize/__tests__/quantizer_celebi.test.ts +195 -0
- package/src/color/quantize/lab_point_provider.ts +55 -0
- package/src/color/quantize/point_provider.ts +27 -0
- package/src/color/quantize/quantizer_celebi.ts +51 -0
- package/src/color/quantize/quantizer_celebi_test.ts +71 -0
- package/src/color/quantize/quantizer_map.ts +47 -0
- package/src/color/quantize/quantizer_wsmeans.ts +232 -0
- package/src/color/quantize/quantizer_wu.ts +472 -0
- package/src/color/score/__tests__/score.test.ts +224 -0
- package/src/color/score/score.ts +175 -0
- package/src/color/types.ts +151 -0
- package/src/color/utils/color_utils.ts +292 -0
- package/src/color/utils/math_utils.ts +145 -0
- package/src/color/utils.test.ts +403 -0
- package/src/color/utils.ts +315 -0
- package/src/constants.ts +5 -0
- package/src/coolors-mcp.ts +37 -0
- package/src/examples/addition.ts +333 -0
- package/src/examples/color-demo.ts +125 -0
- package/src/examples/custom-logger.ts +201 -0
- package/src/examples/oauth-server.ts +113 -0
- package/src/examples/session-context.ts +269 -0
- package/src/session.ts +116 -0
- package/src/theme/__tests__/matcher.test.ts +180 -0
- package/src/theme/__tests__/parser.test.ts +148 -0
- package/src/theme/__tests__/refactor.test.ts +224 -0
- package/src/theme/index.ts +34 -0
- package/src/theme/matcher.ts +395 -0
- package/src/theme/parser.ts +392 -0
- package/src/theme/refactor.ts +360 -0
- package/src/theme/types.ts +152 -0
- package/src/tools/__tests__/gradient-generator.test.ts +206 -0
- package/src/tools/__tests__/palette-with-locks.test.ts +109 -0
- package/src/tools/color-conversion.tool.ts +54 -0
- package/src/tools/color-distance.tool.ts +41 -0
- package/src/tools/colors.ts +31 -0
- package/src/tools/contrast-checker.tool.ts +37 -0
- package/src/tools/dislike-analyzer.tool.ts +247 -0
- package/src/tools/gradient-generator.tool.ts +250 -0
- package/src/tools/image-extraction.tools.ts +289 -0
- package/src/tools/index.ts +39 -0
- package/src/tools/material-theme.tools.ts +250 -0
- package/src/tools/palette-generator.tool.ts +135 -0
- package/src/tools/palette-with-locks.tool.ts +221 -0
- package/src/tools/registry.ts +142 -0
- package/src/tools/simple-tools.ts +37 -0
- package/src/tools/theme-matching.tools.ts +334 -0
- package/src/types.ts +182 -0
- package/src/utils.ts +22 -0
- package/tsconfig.json +8 -0
- 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
|
+
}
|