@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,472 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2021 Google LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+
18
+ import * as utils from "../utils/color_utils.js";
19
+ import { QuantizerMap } from "./quantizer_map.js";
20
+
21
+ const INDEX_BITS = 5;
22
+ const SIDE_LENGTH = 33; // ((1 << INDEX_INDEX_BITS) + 1)
23
+ const TOTAL_SIZE = 35937; // SIDE_LENGTH * SIDE_LENGTH * SIDE_LENGTH
24
+
25
+ const directions = {
26
+ BLUE: "blue",
27
+ GREEN: "green",
28
+ RED: "red",
29
+ };
30
+
31
+ /**
32
+ * Keeps track of the state of each box created as the Wu quantization
33
+ * algorithm progresses through dividing the image's pixels as plotted in RGB.
34
+ */
35
+ class Box {
36
+ constructor(
37
+ public r0: number = 0,
38
+ public r1: number = 0,
39
+ public g0: number = 0,
40
+ public g1: number = 0,
41
+ public b0: number = 0,
42
+ public b1: number = 0,
43
+ public vol: number = 0,
44
+ ) {}
45
+ }
46
+
47
+ /**
48
+ * Represents final result of Wu algorithm.
49
+ */
50
+ class CreateBoxesResult {
51
+ /**
52
+ * @param requestedCount how many colors the caller asked to be returned from
53
+ * quantization.
54
+ * @param resultCount the actual number of colors achieved from quantization.
55
+ * May be lower than the requested count.
56
+ */
57
+ constructor(
58
+ public requestedCount: number,
59
+ public resultCount: number,
60
+ ) {}
61
+ }
62
+
63
+ /**
64
+ * Represents the result of calculating where to cut an existing box in such
65
+ * a way to maximize variance between the two new boxes created by a cut.
66
+ */
67
+ class MaximizeResult {
68
+ constructor(
69
+ public cutLocation: number,
70
+ public maximum: number,
71
+ ) {}
72
+ }
73
+
74
+ /**
75
+ * An image quantizer that divides the image's pixels into clusters by
76
+ * recursively cutting an RGB cube, based on the weight of pixels in each area
77
+ * of the cube.
78
+ *
79
+ * The algorithm was described by Xiaolin Wu in Graphic Gems II, published in
80
+ * 1991.
81
+ */
82
+ export class QuantizerWu {
83
+ constructor(
84
+ private weights: number[] = [],
85
+ private momentsR: number[] = [],
86
+ private momentsG: number[] = [],
87
+ private momentsB: number[] = [],
88
+ private moments: number[] = [],
89
+ private cubes: Box[] = [],
90
+ ) {}
91
+
92
+ /**
93
+ * @param pixels Colors in ARGB format.
94
+ * @param maxColors The number of colors to divide the image into. A lower
95
+ * number of colors may be returned.
96
+ * @return Colors in ARGB format.
97
+ */
98
+ quantize(pixels: number[], maxColors: number): number[] {
99
+ this.constructHistogram(pixels);
100
+ this.computeMoments();
101
+ const createBoxesResult = this.createBoxes(maxColors);
102
+ const results = this.createResult(createBoxesResult.resultCount);
103
+ return results;
104
+ }
105
+
106
+ private bottom(cube: Box, direction: string, moment: number[]) {
107
+ switch (direction) {
108
+ case directions.BLUE:
109
+ return (
110
+ -moment[this.getIndex(cube.r1, cube.g1, cube.b0)] +
111
+ moment[this.getIndex(cube.r1, cube.g0, cube.b0)] +
112
+ moment[this.getIndex(cube.r0, cube.g1, cube.b0)] -
113
+ moment[this.getIndex(cube.r0, cube.g0, cube.b0)]
114
+ );
115
+ case directions.GREEN:
116
+ return (
117
+ -moment[this.getIndex(cube.r1, cube.g0, cube.b1)] +
118
+ moment[this.getIndex(cube.r1, cube.g0, cube.b0)] +
119
+ moment[this.getIndex(cube.r0, cube.g0, cube.b1)] -
120
+ moment[this.getIndex(cube.r0, cube.g0, cube.b0)]
121
+ );
122
+ case directions.RED:
123
+ return (
124
+ -moment[this.getIndex(cube.r0, cube.g1, cube.b1)] +
125
+ moment[this.getIndex(cube.r0, cube.g1, cube.b0)] +
126
+ moment[this.getIndex(cube.r0, cube.g0, cube.b1)] -
127
+ moment[this.getIndex(cube.r0, cube.g0, cube.b0)]
128
+ );
129
+ default:
130
+ throw new Error("unexpected direction $direction");
131
+ }
132
+ }
133
+
134
+ private computeMoments() {
135
+ for (let r = 1; r < SIDE_LENGTH; r++) {
136
+ const area = Array.from<number>({ length: SIDE_LENGTH }).fill(0);
137
+ const areaR = Array.from<number>({ length: SIDE_LENGTH }).fill(0);
138
+ const areaG = Array.from<number>({ length: SIDE_LENGTH }).fill(0);
139
+ const areaB = Array.from<number>({ length: SIDE_LENGTH }).fill(0);
140
+ const area2 = Array.from<number>({ length: SIDE_LENGTH }).fill(0.0);
141
+ for (let g = 1; g < SIDE_LENGTH; g++) {
142
+ let line = 0;
143
+ let lineR = 0;
144
+ let lineG = 0;
145
+ let lineB = 0;
146
+ let line2 = 0.0;
147
+ for (let b = 1; b < SIDE_LENGTH; b++) {
148
+ const index = this.getIndex(r, g, b);
149
+ line += this.weights[index];
150
+ lineR += this.momentsR[index];
151
+ lineG += this.momentsG[index];
152
+ lineB += this.momentsB[index];
153
+ line2 += this.moments[index];
154
+
155
+ area[b] += line;
156
+ areaR[b] += lineR;
157
+ areaG[b] += lineG;
158
+ areaB[b] += lineB;
159
+ area2[b] += line2;
160
+
161
+ const previousIndex = this.getIndex(r - 1, g, b);
162
+ this.weights[index] = this.weights[previousIndex] + area[b];
163
+ this.momentsR[index] = this.momentsR[previousIndex] + areaR[b];
164
+ this.momentsG[index] = this.momentsG[previousIndex] + areaG[b];
165
+ this.momentsB[index] = this.momentsB[previousIndex] + areaB[b];
166
+ this.moments[index] = this.moments[previousIndex] + area2[b];
167
+ }
168
+ }
169
+ }
170
+ }
171
+
172
+ private constructHistogram(pixels: number[]) {
173
+ this.weights = Array.from<number>({ length: TOTAL_SIZE }).fill(0);
174
+ this.momentsR = Array.from<number>({ length: TOTAL_SIZE }).fill(0);
175
+ this.momentsG = Array.from<number>({ length: TOTAL_SIZE }).fill(0);
176
+ this.momentsB = Array.from<number>({ length: TOTAL_SIZE }).fill(0);
177
+ this.moments = Array.from<number>({ length: TOTAL_SIZE }).fill(0);
178
+
179
+ const countByColor = QuantizerMap.quantize(pixels);
180
+
181
+ for (const [pixel, count] of countByColor.entries()) {
182
+ const red = utils.redFromArgb(pixel);
183
+ const green = utils.greenFromArgb(pixel);
184
+ const blue = utils.blueFromArgb(pixel);
185
+
186
+ const bitsToRemove = 8 - INDEX_BITS;
187
+ const iR = (red >> bitsToRemove) + 1;
188
+ const iG = (green >> bitsToRemove) + 1;
189
+ const iB = (blue >> bitsToRemove) + 1;
190
+ const index = this.getIndex(iR, iG, iB);
191
+
192
+ this.weights[index] = (this.weights[index] ?? 0) + count;
193
+ this.momentsR[index] += count * red;
194
+ this.momentsG[index] += count * green;
195
+ this.momentsB[index] += count * blue;
196
+ this.moments[index] += count * (red * red + green * green + blue * blue);
197
+ }
198
+ }
199
+
200
+ private createBoxes(maxColors: number): CreateBoxesResult {
201
+ this.cubes = Array.from<number>({ length: maxColors })
202
+ .fill(0)
203
+ .map(() => new Box());
204
+ const volumeVariance = Array.from<number>({ length: maxColors }).fill(0.0);
205
+ this.cubes[0].r0 = 0;
206
+ this.cubes[0].g0 = 0;
207
+ this.cubes[0].b0 = 0;
208
+
209
+ this.cubes[0].r1 = SIDE_LENGTH - 1;
210
+ this.cubes[0].g1 = SIDE_LENGTH - 1;
211
+ this.cubes[0].b1 = SIDE_LENGTH - 1;
212
+
213
+ let generatedColorCount = maxColors;
214
+ let next = 0;
215
+ for (let i = 1; i < maxColors; i++) {
216
+ if (this.cut(this.cubes[next], this.cubes[i])) {
217
+ volumeVariance[next] =
218
+ this.cubes[next].vol > 1 ? this.variance(this.cubes[next]) : 0.0;
219
+ volumeVariance[i] =
220
+ this.cubes[i].vol > 1 ? this.variance(this.cubes[i]) : 0.0;
221
+ } else {
222
+ volumeVariance[next] = 0.0;
223
+ i--;
224
+ }
225
+
226
+ next = 0;
227
+ let temp = volumeVariance[0];
228
+ for (let j = 1; j <= i; j++) {
229
+ if (volumeVariance[j] > temp) {
230
+ temp = volumeVariance[j];
231
+ next = j;
232
+ }
233
+ }
234
+ if (temp <= 0.0) {
235
+ generatedColorCount = i + 1;
236
+ break;
237
+ }
238
+ }
239
+ return new CreateBoxesResult(maxColors, generatedColorCount);
240
+ }
241
+
242
+ private createResult(colorCount: number): number[] {
243
+ const colors: number[] = [];
244
+ for (let i = 0; i < colorCount; ++i) {
245
+ const cube = this.cubes[i];
246
+ const weight = this.volume(cube, this.weights);
247
+ if (weight > 0) {
248
+ const r = Math.round(this.volume(cube, this.momentsR) / weight);
249
+ const g = Math.round(this.volume(cube, this.momentsG) / weight);
250
+ const b = Math.round(this.volume(cube, this.momentsB) / weight);
251
+ const color =
252
+ (255 << 24) | ((r & 0x0ff) << 16) | ((g & 0x0ff) << 8) | (b & 0x0ff);
253
+ colors.push(color);
254
+ }
255
+ }
256
+ return colors;
257
+ }
258
+
259
+ private cut(one: Box, two: Box) {
260
+ const wholeR = this.volume(one, this.momentsR);
261
+ const wholeG = this.volume(one, this.momentsG);
262
+ const wholeB = this.volume(one, this.momentsB);
263
+ const wholeW = this.volume(one, this.weights);
264
+
265
+ const maxRResult = this.maximize(
266
+ one,
267
+ directions.RED,
268
+ one.r0 + 1,
269
+ one.r1,
270
+ wholeR,
271
+ wholeG,
272
+ wholeB,
273
+ wholeW,
274
+ );
275
+ const maxGResult = this.maximize(
276
+ one,
277
+ directions.GREEN,
278
+ one.g0 + 1,
279
+ one.g1,
280
+ wholeR,
281
+ wholeG,
282
+ wholeB,
283
+ wholeW,
284
+ );
285
+ const maxBResult = this.maximize(
286
+ one,
287
+ directions.BLUE,
288
+ one.b0 + 1,
289
+ one.b1,
290
+ wholeR,
291
+ wholeG,
292
+ wholeB,
293
+ wholeW,
294
+ );
295
+
296
+ let direction;
297
+ const maxR = maxRResult.maximum;
298
+ const maxG = maxGResult.maximum;
299
+ const maxB = maxBResult.maximum;
300
+ if (maxR >= maxG && maxR >= maxB) {
301
+ if (maxRResult.cutLocation < 0) {
302
+ return false;
303
+ }
304
+ direction = directions.RED;
305
+ } else if (maxG >= maxR && maxG >= maxB) {
306
+ direction = directions.GREEN;
307
+ } else {
308
+ direction = directions.BLUE;
309
+ }
310
+
311
+ two.r1 = one.r1;
312
+ two.g1 = one.g1;
313
+ two.b1 = one.b1;
314
+
315
+ switch (direction) {
316
+ case directions.BLUE:
317
+ one.b1 = maxBResult.cutLocation;
318
+ two.r0 = one.r0;
319
+ two.g0 = one.g0;
320
+ two.b0 = one.b1;
321
+ break;
322
+ case directions.GREEN:
323
+ one.g1 = maxGResult.cutLocation;
324
+ two.r0 = one.r0;
325
+ two.g0 = one.g1;
326
+ two.b0 = one.b0;
327
+ break;
328
+ case directions.RED:
329
+ one.r1 = maxRResult.cutLocation;
330
+ two.r0 = one.r1;
331
+ two.g0 = one.g0;
332
+ two.b0 = one.b0;
333
+ break;
334
+ default:
335
+ throw new Error("unexpected direction " + direction);
336
+ }
337
+
338
+ one.vol = (one.r1 - one.r0) * (one.g1 - one.g0) * (one.b1 - one.b0);
339
+ two.vol = (two.r1 - two.r0) * (two.g1 - two.g0) * (two.b1 - two.b0);
340
+ return true;
341
+ }
342
+
343
+ private getIndex(r: number, g: number, b: number): number {
344
+ return (
345
+ (r << (INDEX_BITS * 2)) +
346
+ (r << (INDEX_BITS + 1)) +
347
+ r +
348
+ (g << INDEX_BITS) +
349
+ g +
350
+ b
351
+ );
352
+ }
353
+
354
+ private maximize(
355
+ cube: Box,
356
+ direction: string,
357
+ first: number,
358
+ last: number,
359
+ wholeR: number,
360
+ wholeG: number,
361
+ wholeB: number,
362
+ wholeW: number,
363
+ ) {
364
+ const bottomR = this.bottom(cube, direction, this.momentsR);
365
+ const bottomG = this.bottom(cube, direction, this.momentsG);
366
+ const bottomB = this.bottom(cube, direction, this.momentsB);
367
+ const bottomW = this.bottom(cube, direction, this.weights);
368
+
369
+ let max = 0.0;
370
+ let cut = -1;
371
+
372
+ let halfR = 0;
373
+ let halfG = 0;
374
+ let halfB = 0;
375
+ let halfW = 0;
376
+ for (let i = first; i < last; i++) {
377
+ halfR = bottomR + this.top(cube, direction, i, this.momentsR);
378
+ halfG = bottomG + this.top(cube, direction, i, this.momentsG);
379
+ halfB = bottomB + this.top(cube, direction, i, this.momentsB);
380
+ halfW = bottomW + this.top(cube, direction, i, this.weights);
381
+ if (halfW === 0) {
382
+ continue;
383
+ }
384
+
385
+ let tempNumerator = (halfR * halfR + halfG * halfG + halfB * halfB) * 1.0;
386
+ let tempDenominator = halfW * 1.0;
387
+ let temp = tempNumerator / tempDenominator;
388
+
389
+ halfR = wholeR - halfR;
390
+ halfG = wholeG - halfG;
391
+ halfB = wholeB - halfB;
392
+ halfW = wholeW - halfW;
393
+ if (halfW === 0) {
394
+ continue;
395
+ }
396
+
397
+ tempNumerator = (halfR * halfR + halfG * halfG + halfB * halfB) * 1.0;
398
+ tempDenominator = halfW * 1.0;
399
+ temp += tempNumerator / tempDenominator;
400
+
401
+ if (temp > max) {
402
+ max = temp;
403
+ cut = i;
404
+ }
405
+ }
406
+ return new MaximizeResult(cut, max);
407
+ }
408
+
409
+ private top(
410
+ cube: Box,
411
+ direction: string,
412
+ position: number,
413
+ moment: number[],
414
+ ) {
415
+ switch (direction) {
416
+ case directions.BLUE:
417
+ return (
418
+ moment[this.getIndex(cube.r1, cube.g1, position)] -
419
+ moment[this.getIndex(cube.r1, cube.g0, position)] -
420
+ moment[this.getIndex(cube.r0, cube.g1, position)] +
421
+ moment[this.getIndex(cube.r0, cube.g0, position)]
422
+ );
423
+ case directions.GREEN:
424
+ return (
425
+ moment[this.getIndex(cube.r1, position, cube.b1)] -
426
+ moment[this.getIndex(cube.r1, position, cube.b0)] -
427
+ moment[this.getIndex(cube.r0, position, cube.b1)] +
428
+ moment[this.getIndex(cube.r0, position, cube.b0)]
429
+ );
430
+ case directions.RED:
431
+ return (
432
+ moment[this.getIndex(position, cube.g1, cube.b1)] -
433
+ moment[this.getIndex(position, cube.g1, cube.b0)] -
434
+ moment[this.getIndex(position, cube.g0, cube.b1)] +
435
+ moment[this.getIndex(position, cube.g0, cube.b0)]
436
+ );
437
+ default:
438
+ throw new Error("unexpected direction $direction");
439
+ }
440
+ }
441
+
442
+ private variance(cube: Box) {
443
+ const dr = this.volume(cube, this.momentsR);
444
+ const dg = this.volume(cube, this.momentsG);
445
+ const db = this.volume(cube, this.momentsB);
446
+ const xx =
447
+ this.moments[this.getIndex(cube.r1, cube.g1, cube.b1)] -
448
+ this.moments[this.getIndex(cube.r1, cube.g1, cube.b0)] -
449
+ this.moments[this.getIndex(cube.r1, cube.g0, cube.b1)] +
450
+ this.moments[this.getIndex(cube.r1, cube.g0, cube.b0)] -
451
+ this.moments[this.getIndex(cube.r0, cube.g1, cube.b1)] +
452
+ this.moments[this.getIndex(cube.r0, cube.g1, cube.b0)] +
453
+ this.moments[this.getIndex(cube.r0, cube.g0, cube.b1)] -
454
+ this.moments[this.getIndex(cube.r0, cube.g0, cube.b0)];
455
+ const hypotenuse = dr * dr + dg * dg + db * db;
456
+ const volume = this.volume(cube, this.weights);
457
+ return xx - hypotenuse / volume;
458
+ }
459
+
460
+ private volume(cube: Box, moment: number[]) {
461
+ return (
462
+ moment[this.getIndex(cube.r1, cube.g1, cube.b1)] -
463
+ moment[this.getIndex(cube.r1, cube.g1, cube.b0)] -
464
+ moment[this.getIndex(cube.r1, cube.g0, cube.b1)] +
465
+ moment[this.getIndex(cube.r1, cube.g0, cube.b0)] -
466
+ moment[this.getIndex(cube.r0, cube.g1, cube.b1)] +
467
+ moment[this.getIndex(cube.r0, cube.g1, cube.b0)] +
468
+ moment[this.getIndex(cube.r0, cube.g0, cube.b1)] -
469
+ moment[this.getIndex(cube.r0, cube.g0, cube.b0)]
470
+ );
471
+ }
472
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Tests for color scoring algorithm
3
+ */
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import { Hct } from "../../hct/hct-class";
8
+ import * as utils from "../../utils/color_utils";
9
+ import { Score } from "../score";
10
+
11
+ describe("Score", () => {
12
+ describe("score method", () => {
13
+ it("should return fallback color for empty input", () => {
14
+ const colorsToPopulation = new Map<number, number>();
15
+ const result = Score.score(colorsToPopulation);
16
+
17
+ expect(result).toHaveLength(1);
18
+ expect(result[0]).toBe(0xff4285f4); // Default Google Blue
19
+ });
20
+
21
+ it("should return custom fallback color when specified", () => {
22
+ const colorsToPopulation = new Map<number, number>();
23
+ const customFallback = utils.argbFromRgb(255, 0, 0);
24
+
25
+ const result = Score.score(colorsToPopulation, {
26
+ fallbackColorARGB: customFallback,
27
+ });
28
+
29
+ expect(result).toHaveLength(1);
30
+ expect(result[0]).toBe(customFallback);
31
+ });
32
+
33
+ it("should filter low chroma colors when filter is true", () => {
34
+ const grayColor = utils.argbFromRgb(128, 128, 128); // Low chroma
35
+ const vibrantColor = utils.argbFromRgb(255, 0, 0); // High chroma
36
+
37
+ const colorsToPopulation = new Map([
38
+ [grayColor, 100],
39
+ [vibrantColor, 50],
40
+ ]);
41
+
42
+ const result = Score.score(colorsToPopulation, {
43
+ desired: 2,
44
+ filter: true,
45
+ });
46
+
47
+ // Should filter out gray color
48
+ expect(result).not.toContain(grayColor);
49
+ expect(result).toContain(vibrantColor);
50
+ });
51
+
52
+ it("should keep all colors when filter is false", () => {
53
+ const grayColor = utils.argbFromRgb(128, 128, 128);
54
+ const vibrantColor = utils.argbFromRgb(255, 0, 0);
55
+
56
+ const colorsToPopulation = new Map([
57
+ [grayColor, 100],
58
+ [vibrantColor, 50],
59
+ ]);
60
+
61
+ const result = Score.score(colorsToPopulation, {
62
+ desired: 2,
63
+ filter: false,
64
+ });
65
+
66
+ // Should keep both colors
67
+ expect(result).toHaveLength(2);
68
+ });
69
+
70
+ it("should return desired number of colors", () => {
71
+ const colors = new Map<number, number>();
72
+
73
+ // Add many vibrant colors
74
+ for (let i = 0; i < 10; i++) {
75
+ const hue = i * 36; // Evenly distributed hues
76
+ const hct = Hct.from(hue, 50, 50);
77
+ colors.set(hct.toInt(), 100 - i);
78
+ }
79
+
80
+ const result = Score.score(colors, {
81
+ desired: 5,
82
+ filter: false,
83
+ });
84
+
85
+ expect(result.length).toBeLessThanOrEqual(5);
86
+ });
87
+
88
+ it("should prioritize colors with good hue distribution", () => {
89
+ // Create colors with different hue separations
90
+ const red = Hct.from(0, 50, 50).toInt();
91
+ const green = Hct.from(120, 50, 50).toInt(); // 120 degrees from red
92
+ const blue = Hct.from(240, 50, 50).toInt(); // 240 degrees from red
93
+ const orange = Hct.from(30, 50, 50).toInt(); // Only 30 degrees from red
94
+
95
+ const colorsToPopulation = new Map([
96
+ [blue, 100],
97
+ [green, 100],
98
+ [orange, 100],
99
+ [red, 100],
100
+ ]);
101
+
102
+ const result = Score.score(colorsToPopulation, {
103
+ desired: 3,
104
+ filter: false,
105
+ });
106
+
107
+ // Should prefer colors with better hue distribution
108
+ expect(result).toContain(red);
109
+ expect(result).toContain(green);
110
+ expect(result).toContain(blue);
111
+ // Orange might be excluded due to being too close to red
112
+ });
113
+
114
+ it("should score colors based on chroma proximity to target", () => {
115
+ // Target chroma is 48.0
116
+ // Higher chroma is preferred with weight 0.3, lower chroma has weight 0.1
117
+ const perfectChroma = Hct.from(180, 48, 50).toInt();
118
+ const lowChroma = Hct.from(180, 20, 50).toInt();
119
+ const highChroma = Hct.from(180, 80, 50).toInt();
120
+
121
+ const colorsToPopulation = new Map([
122
+ [highChroma, 100],
123
+ [lowChroma, 100],
124
+ [perfectChroma, 100],
125
+ ]);
126
+
127
+ const result = Score.score(colorsToPopulation, {
128
+ desired: 1,
129
+ filter: false,
130
+ });
131
+
132
+ // High chroma should score highest (algorithm prefers vibrant colors)
133
+ expect(result[0]).toBe(highChroma);
134
+ });
135
+
136
+ it("should filter colors with very low population proportion", () => {
137
+ const commonColor = utils.argbFromRgb(255, 0, 0);
138
+ const rareColor = utils.argbFromRgb(0, 255, 0);
139
+
140
+ const colorsToPopulation = new Map([
141
+ [commonColor, 1000],
142
+ [rareColor, 1], // Very rare (0.1% of population)
143
+ ]);
144
+
145
+ const result = Score.score(colorsToPopulation, {
146
+ desired: 2,
147
+ filter: true,
148
+ });
149
+
150
+ // Rare color should be filtered out
151
+ expect(result).toContain(commonColor);
152
+ expect(result).not.toContain(rareColor);
153
+ });
154
+
155
+ it("should handle monochrome images", () => {
156
+ const black = utils.argbFromRgb(0, 0, 0);
157
+ const gray = utils.argbFromRgb(128, 128, 128);
158
+ const white = utils.argbFromRgb(255, 255, 255);
159
+
160
+ const colorsToPopulation = new Map([
161
+ [black, 100],
162
+ [gray, 100],
163
+ [white, 100],
164
+ ]);
165
+
166
+ const result = Score.score(colorsToPopulation, {
167
+ desired: 3,
168
+ filter: true,
169
+ });
170
+
171
+ // All grayscale colors have low chroma, might return fallback
172
+ if (result.length === 1) {
173
+ expect(result[0]).toBe(0xff4285f4); // Fallback color
174
+ }
175
+ });
176
+
177
+ it("should maintain minimum hue difference between selected colors", () => {
178
+ const colors = new Map<number, number>();
179
+
180
+ // Add colors with small hue differences
181
+ for (let i = 0; i < 20; i++) {
182
+ const hue = i * 5; // 5 degree increments
183
+ const hct = Hct.from(hue, 50, 50);
184
+ colors.set(hct.toInt(), 100);
185
+ }
186
+
187
+ const result = Score.score(colors, {
188
+ desired: 4,
189
+ filter: false,
190
+ });
191
+
192
+ // Check that selected colors have reasonable hue separation
193
+ if (result.length > 1) {
194
+ const hues = result.map((argb) => Hct.fromInt(argb).hue);
195
+
196
+ for (let i = 0; i < hues.length; i++) {
197
+ for (let j = i + 1; j < hues.length; j++) {
198
+ const diff = Math.abs(hues[i] - hues[j]);
199
+ const normalizedDiff = Math.min(diff, 360 - diff);
200
+
201
+ // Should maintain at least 15 degrees separation
202
+ expect(normalizedDiff).toBeGreaterThanOrEqual(15);
203
+ }
204
+ }
205
+ }
206
+ });
207
+
208
+ it("should work with default options", () => {
209
+ const color1 = utils.argbFromRgb(255, 0, 0);
210
+ const color2 = utils.argbFromRgb(0, 255, 0);
211
+
212
+ const colorsToPopulation = new Map([
213
+ [color1, 100],
214
+ [color2, 50],
215
+ ]);
216
+
217
+ const result = Score.score(colorsToPopulation);
218
+
219
+ // Should use default desired (4) and filter (true)
220
+ expect(result.length).toBeGreaterThan(0);
221
+ expect(result.length).toBeLessThanOrEqual(4);
222
+ });
223
+ });
224
+ });