1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 | import { APCAcontrast, sRGBtoY } from "apca-w3";
|
14 | import chroma from "chroma-js";
|
15 | import { catmullRom2bezier, prepareCurve } from "./curve";
|
16 |
|
17 | const colorSpaces = {
|
18 | CAM02: 'jab',
|
19 | CAM02p: 'jch',
|
20 | HEX: 'hex',
|
21 | HSL: 'hsl',
|
22 | HSLuv: 'hsluv',
|
23 | HSV: 'hsv',
|
24 | LAB: 'lab',
|
25 | LCH: 'lch',
|
26 | RGB: 'rgb',
|
27 | OKLAB: 'oklab',
|
28 | OKLCH: 'oklch'
|
29 | };
|
30 |
|
31 | function round(x, n = 0) {
|
32 | const ten = 10 ** n;
|
33 | return Math.round(x * ten) / ten;
|
34 | }
|
35 |
|
36 | function multiplyRatios(ratio, multiplier) {
|
37 | let r;
|
38 |
|
39 |
|
40 |
|
41 | if (ratio > 1) {
|
42 | r = (ratio - 1) * multiplier + 1;
|
43 | } else if (ratio < -1) {
|
44 | r = (ratio + 1) * multiplier - 1;
|
45 | } else {
|
46 | r = 1;
|
47 | }
|
48 |
|
49 | return round(r, 2);
|
50 | }
|
51 |
|
52 | function cArray(c) {
|
53 | return chroma(String(c)).jch();
|
54 | }
|
55 |
|
56 | function hsluvArray(c) {
|
57 | return chroma(String(c)).hsluv();
|
58 | }
|
59 |
|
60 | function smoothScale(ColorsArray, domains, space) {
|
61 | const points = [[], [], []];
|
62 | ColorsArray.forEach((color, i) => points.forEach((point, j) => point.push(domains[i], color[j])));
|
63 | if (space === 'hcl') {
|
64 | const point = points[1];
|
65 | for (let i = 1; i < point.length; i += 2) {
|
66 | if (Number.isNaN(point[i])) {
|
67 | point[i] = 0;
|
68 | }
|
69 | }
|
70 | }
|
71 | points.forEach((point) => {
|
72 | const nans = [];
|
73 |
|
74 | for (let i = 1; i < point.length; i += 2) {
|
75 | if (Number.isNaN(point[i])) {
|
76 | nans.push(i);
|
77 | } else {
|
78 | nans.forEach((j) => { point[j] = point[i]; });
|
79 | nans.length = 0;
|
80 | break;
|
81 | }
|
82 | }
|
83 |
|
84 | if (nans.length) {
|
85 |
|
86 | const safeJChHue = chroma('#ccc').jch()[2];
|
87 | nans.forEach((j) => { point[j] = safeJChHue; });
|
88 | }
|
89 | nans.length = 0;
|
90 |
|
91 | for (let i = point.length - 1; i > 0; i -= 2) {
|
92 | if (Number.isNaN(point[i])) {
|
93 | nans.push(i);
|
94 | } else {
|
95 | nans.forEach((j) => { point[j] = point[i]; });
|
96 | break;
|
97 | }
|
98 | }
|
99 |
|
100 | for (let i = 1; i < point.length; i += 2) {
|
101 | if (Number.isNaN(point[i])) {
|
102 | point.splice(i - 1, 2);
|
103 | i -= 2;
|
104 | }
|
105 | }
|
106 |
|
107 | if (space in { hcl: 1, hsl: 1, hsluv: 1, hsv: 1, jch: 1 }) {
|
108 | let prev = point[1];
|
109 | let addon = 0;
|
110 | for (let i = 3; i < point.length; i += 2) {
|
111 | const p = point[i] + addon;
|
112 | const zero = Math.abs(prev - p);
|
113 | const plus = Math.abs(prev - (p + 360));
|
114 | const minus = Math.abs(prev - (p - 360));
|
115 | if (plus < zero && plus < minus) {
|
116 | addon += 360;
|
117 | }
|
118 | if (minus < zero && minus < plus) {
|
119 | addon -= 360;
|
120 | }
|
121 | point[i] += addon;
|
122 | prev = point[i];
|
123 | }
|
124 | }
|
125 | });
|
126 | const prep = points.map((point) => catmullRom2bezier(point).map((curve) => prepareCurve(...curve)));
|
127 | return (d) => {
|
128 | const ch = prep.map((p) => {
|
129 | for (let i = 0; i < p.length; i++) {
|
130 | const res = p[i](d);
|
131 | if (res != null) {
|
132 | return res;
|
133 | }
|
134 | }
|
135 | return null;
|
136 | });
|
137 |
|
138 | if (space === 'jch' && ch[1] < 0) {
|
139 | ch[1] = 0;
|
140 | }
|
141 |
|
142 | return chroma[space](...ch).hex();
|
143 | };
|
144 | }
|
145 |
|
146 | function makePowScale(exp = 1, domains = [0, 1], range = [0, 1]) {
|
147 | const m = (range[1] - range[0]) / (domains[1] ** exp - domains[0] ** exp);
|
148 | const c = range[0] - m * domains[0] ** exp;
|
149 | return (x) => m * x ** exp + c;
|
150 | }
|
151 |
|
152 | function createScale({
|
153 | swatches,
|
154 | colorKeys,
|
155 | colorspace = 'LAB',
|
156 | shift = 1,
|
157 | fullScale = true,
|
158 | smooth = false,
|
159 | distributeLightness = 'linear',
|
160 | sortColor = true,
|
161 | asFun = false,
|
162 | } = {}) {
|
163 | const space = colorSpaces[colorspace];
|
164 | if (!space) {
|
165 | throw new Error(`Colorspace “${colorspace}” not supported`);
|
166 | }
|
167 | if (!colorKeys) {
|
168 | throw new Error(`Colorkeys missing: returned “${colorKeys}”`);
|
169 | }
|
170 |
|
171 | let domains;
|
172 |
|
173 | if(fullScale) {
|
174 |
|
175 |
|
176 | domains = colorKeys
|
177 | .map((key) => swatches - swatches * (chroma(key).jch()[0] / 100))
|
178 | .sort((a, b) => a - b)
|
179 | .concat(swatches);
|
180 |
|
181 | domains.unshift(0);
|
182 | } else {
|
183 |
|
184 | let lums = colorKeys.map((c) => chroma(c).jch()[0] / 100)
|
185 | let min = Math.min(...lums);
|
186 | let max = Math.max(...lums);
|
187 |
|
188 | domains = lums
|
189 | .map((lum) => {
|
190 | if(lum === 0 || isNaN((lum - min) / (max - min))) return 0;
|
191 | else return swatches - (lum - min) / (max - min) * swatches;
|
192 | })
|
193 | .sort((a, b) => a - b)
|
194 | }
|
195 |
|
196 |
|
197 | let sqrtDomains = makePowScale(shift, [1, swatches], [1, swatches]);
|
198 | sqrtDomains = domains.map((d) => Math.max(0, sqrtDomains(d)));
|
199 |
|
200 |
|
201 | domains = sqrtDomains;
|
202 |
|
203 |
|
204 |
|
205 |
|
206 |
|
207 |
|
208 | if(distributeLightness === 'polynomial') {
|
209 |
|
210 |
|
211 |
|
212 |
|
213 | const polynomial = (x) => { return Math.sqrt(Math.sqrt((Math.pow(x, 2.25) + Math.pow(x, 4))/2)) }
|
214 |
|
215 | let percDomains = sqrtDomains.map((d) => {return d/swatches})
|
216 | let newDomains = percDomains.map((d) => {return polynomial(d) * swatches})
|
217 | domains = newDomains;
|
218 | }
|
219 |
|
220 | const sortedColor = colorKeys
|
221 |
|
222 | .map((c, i) => ({ colorKeys: cArray(c), index: i }))
|
223 |
|
224 | .sort((c1, c2) => c2.colorKeys[0] - c1.colorKeys[0])
|
225 |
|
226 | .map((data) => colorKeys[data.index]);
|
227 |
|
228 | let ColorsArray = [];
|
229 |
|
230 | let scale;
|
231 | if (fullScale) {
|
232 | const white = space === 'lch' ? chroma.lch(...chroma('#fff').lch()) : '#ffffff';
|
233 | const black = space === 'lch' ? chroma.lch(...chroma('#000').lch()) : '#000000';
|
234 | ColorsArray = [
|
235 | white,
|
236 | ...sortedColor,
|
237 | black,
|
238 | ];
|
239 | } else {
|
240 | if(sortColor) ColorsArray = sortedColor;
|
241 | else ColorsArray = colorKeys;
|
242 | }
|
243 |
|
244 | let smoothScaleArray;
|
245 | if (smooth) {
|
246 | const stringColors = ColorsArray;
|
247 | ColorsArray = ColorsArray.map((d) => chroma(String(d))[space]());
|
248 | if (space === 'hcl') {
|
249 |
|
250 | ColorsArray.forEach((c) => { c[1] = Number.isNaN(c[1]) ? 0 : c[1]; });
|
251 | }
|
252 | if (space === 'jch') {
|
253 |
|
254 |
|
255 | for (let i = 0; i < stringColors.length; i++) {
|
256 | const color = chroma(stringColors[i]).hcl();
|
257 | if (Number.isNaN(color[0])) {
|
258 | ColorsArray[i][2] = NaN;
|
259 | }
|
260 | }
|
261 | }
|
262 | scale = smoothScale(ColorsArray, domains, space);
|
263 |
|
264 | smoothScaleArray = new Array(swatches).fill().map((_, d) => scale(d));
|
265 | } else {
|
266 | scale = chroma.scale(ColorsArray.map((color) => {
|
267 | if (typeof color === 'object' && color.constructor === chroma.Color) {
|
268 | return color;
|
269 | }
|
270 | return String(color);
|
271 | })).domain(domains).mode(space);
|
272 | }
|
273 | if (asFun) {
|
274 | return scale;
|
275 | }
|
276 |
|
277 |
|
278 | const Colors =
|
279 | (!smooth || smooth === false) ?
|
280 | scale.colors(swatches) :
|
281 | smoothScaleArray;
|
282 |
|
283 | const colors = Colors.filter((el) => el != null);
|
284 |
|
285 | return colors;
|
286 | }
|
287 |
|
288 | function removeDuplicates(originalArray, prop) {
|
289 | const newArray = [];
|
290 | const lookupObject = {};
|
291 | const keys1 = Object.keys(originalArray);
|
292 |
|
293 | keys1.forEach((i) => { lookupObject[originalArray[i][prop]] = originalArray[i]; });
|
294 |
|
295 | const keys2 = Object.keys(lookupObject);
|
296 | keys2.forEach((i) => newArray.push(lookupObject[i]));
|
297 | return newArray;
|
298 | }
|
299 |
|
300 | function uniq(a) {
|
301 | return Array.from(new Set(a));
|
302 | }
|
303 |
|
304 |
|
305 | function filterNaN(x) {
|
306 | if (Number.isNaN(x)) {
|
307 | return 0;
|
308 | }
|
309 | return x;
|
310 | }
|
311 |
|
312 |
|
313 | function convertColorValue(color, format, object = false) {
|
314 | if (!color) {
|
315 | throw new Error(`Cannot convert color value of “${color}”`);
|
316 | }
|
317 | if (!colorSpaces[format]) {
|
318 | throw new Error(`Cannot convert to colorspace “${format}”`);
|
319 | }
|
320 | const space = colorSpaces[format];
|
321 | const colorObj = chroma(String(color))[space]();
|
322 | if (format === 'HSL') {
|
323 | colorObj.pop();
|
324 | }
|
325 | if (format === 'HEX') {
|
326 | if (object) {
|
327 | const rgb = chroma(String(color)).rgb();
|
328 | return { r: rgb[0], g: rgb[1], b: rgb[2] };
|
329 | }
|
330 | return colorObj;
|
331 | }
|
332 |
|
333 | const colorObject = {};
|
334 | let newColorObj = colorObj.map(filterNaN);
|
335 |
|
336 | newColorObj = newColorObj.map((ch, i) => {
|
337 | let rnd = round(ch);
|
338 | let j = i;
|
339 | if (space === 'hsluv') {
|
340 | j += 2;
|
341 | }
|
342 | let letter = space.charAt(j);
|
343 | if (space === 'jch' && letter === 'c') {
|
344 | letter = 'C';
|
345 | }
|
346 | colorObject[letter === 'j' ? 'J' : letter] = rnd;
|
347 | if (space in { lab: 1, lch: 1, jab: 1, jch: 1 }) {
|
348 | if (!object) {
|
349 | if (letter === 'l' || letter === 'j') {
|
350 | rnd += '%';
|
351 | }
|
352 | if (letter === 'h') {
|
353 | rnd += 'deg';
|
354 | }
|
355 | }
|
356 | } else if (space !== 'hsluv') {
|
357 | if (letter === 's' || letter === 'l' || letter === 'v') {
|
358 | colorObject[letter] = round(ch, 2);
|
359 | if (!object) {
|
360 | rnd = round(ch * 100);
|
361 | rnd += '%';
|
362 | }
|
363 | } else if (letter === 'h' && !object) {
|
364 | rnd += 'deg';
|
365 | }
|
366 | }
|
367 | return rnd;
|
368 | });
|
369 |
|
370 | const stringName = space;
|
371 | const stringValue = `${stringName}(${newColorObj.join(', ')})`;
|
372 |
|
373 | if (object) {
|
374 | return colorObject;
|
375 | }
|
376 | return stringValue;
|
377 | }
|
378 |
|
379 | function luminance(r, g, b) {
|
380 | const a = [r, g, b].map((v) => {
|
381 | v /= 255;
|
382 | return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
383 | });
|
384 | return (a[0] * 0.2126) + (a[1] * 0.7152) + (a[2] * 0.0722);
|
385 | }
|
386 |
|
387 | function getContrast(color, base, baseV, method='wcag2') {
|
388 | if(method === 'wcag2') {
|
389 | if (baseV === undefined) {
|
390 | const baseLightness = chroma.rgb(...base).hsluv()[2];
|
391 | baseV = round(baseLightness / 100, 2);
|
392 | }
|
393 |
|
394 | const colorLum = luminance(color[0], color[1], color[2]);
|
395 | const baseLum = luminance(base[0], base[1], base[2]);
|
396 |
|
397 | const cr1 = (colorLum + 0.05) / (baseLum + 0.05);
|
398 | const cr2 = (baseLum + 0.05) / (colorLum + 0.05);
|
399 |
|
400 | if (baseV < 0.5) {
|
401 |
|
402 | if (cr1 >= 1) {
|
403 | return cr1;
|
404 | }
|
405 |
|
406 | return -cr2;
|
407 | }
|
408 |
|
409 |
|
410 | if (cr1 < 1) {
|
411 | return cr2;
|
412 | }
|
413 |
|
414 | if (cr1 === 1) {
|
415 | return cr1;
|
416 | }
|
417 | return -cr1;
|
418 | } else if (method === 'wcag3') {
|
419 | return APCAcontrast( sRGBtoY( color ), sRGBtoY( base ) );
|
420 | } else {
|
421 | throw new Error(`Contrast calculation method ${method} unsupported; use 'wcag2' or 'wcag3'`);
|
422 | }
|
423 | }
|
424 |
|
425 | function minPositive(r, formula) {
|
426 | if (!r) { throw new Error('Array undefined'); }
|
427 | if (!Array.isArray(r)) { throw new Error('Passed object is not an array'); }
|
428 | const min = (formula === 'wcag2') ? 0 : 1;
|
429 | return Math.min(...r.filter((val) => val >= min));
|
430 | }
|
431 |
|
432 | function ratioName(r, formula) {
|
433 | if (!r) { throw new Error('Ratios undefined'); }
|
434 | r = r.sort((a, b) => a - b);
|
435 |
|
436 | const min = minPositive(r, formula);
|
437 | const minIndex = r.indexOf(min);
|
438 | const nArr = [];
|
439 |
|
440 | const rNeg = r.slice(0, minIndex);
|
441 | const rPos = r.slice(minIndex, r.length);
|
442 |
|
443 |
|
444 | for (let i = 0; i < rNeg.length; i++) {
|
445 | const d = 1 / (rNeg.length + 1);
|
446 | const m = d * 100;
|
447 | const nVal = m * (i + 1);
|
448 | nArr.push(round(nVal));
|
449 | }
|
450 |
|
451 | for (let i = 0; i < rPos.length; i++) {
|
452 | nArr.push((i + 1) * 100);
|
453 | }
|
454 | nArr.sort((a, b) => a - b);
|
455 |
|
456 | return nArr;
|
457 | }
|
458 |
|
459 | const searchColors = (color, bgRgbArray, baseV, ratioValues, formula) => {
|
460 | const colorLen = 3000;
|
461 | const colorScale = createScale({
|
462 | swatches: colorLen,
|
463 | colorKeys: color._modifiedKeys,
|
464 | colorspace: color._colorspace,
|
465 | shift: 1,
|
466 | smooth: color._smooth,
|
467 | asFun: true,
|
468 | });
|
469 | const ccache = {};
|
470 |
|
471 | const getContrast2 = (i) => {
|
472 | if (ccache[i]) {
|
473 | return ccache[i];
|
474 | }
|
475 | const rgb = chroma(colorScale(i)).rgb();
|
476 | const c = getContrast(rgb, bgRgbArray, baseV, formula);
|
477 | ccache[i] = c;
|
478 |
|
479 | return c;
|
480 | };
|
481 | const colorSearch = (x) => {
|
482 | const first = getContrast2(0);
|
483 | const last = getContrast2(colorLen);
|
484 | const dir = first < last ? 1 : -1;
|
485 | const ε = 0.01;
|
486 | x += 0.005 * Math.sign(x);
|
487 | let step = colorLen / 2;
|
488 | let dot = step;
|
489 | let val = getContrast2(dot);
|
490 | let counter = 100;
|
491 | while (Math.abs(val - x) > ε && counter) {
|
492 | counter--;
|
493 | step /= 2;
|
494 | if (val < x) {
|
495 | dot += step * dir;
|
496 | } else {
|
497 | dot -= step * dir;
|
498 | }
|
499 | val = getContrast2(dot);
|
500 | }
|
501 | return round(dot, 3);
|
502 | };
|
503 | const outputColors = [];
|
504 | ratioValues.forEach((ratio) => outputColors.push(colorScale(colorSearch(+ratio))));
|
505 | return outputColors;
|
506 | };
|
507 |
|
508 | export {
|
509 | cArray,
|
510 | hsluvArray,
|
511 | colorSpaces,
|
512 | convertColorValue,
|
513 | createScale,
|
514 | getContrast,
|
515 | luminance,
|
516 | minPositive,
|
517 | multiplyRatios,
|
518 | ratioName,
|
519 | removeDuplicates,
|
520 | round,
|
521 | searchColors,
|
522 | uniq,
|
523 | };
|