UNPKG

15.1 kBJavaScriptView Raw
1/*
2Copyright 2019 Adobe. All rights reserved.
3This file is licensed to you under the Apache License, Version 2.0 (the "License");
4you may not use this file except in compliance with the License. You may obtain a copy
5of the License at http://www.apache.org/licenses/LICENSE-2.0
6
7Unless required by applicable law or agreed to in writing, software distributed under
8the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9OF ANY KIND, either express or implied. See the License for the specific language
10governing permissions and limitations under the License.
11*/
12
13import { APCAcontrast, sRGBtoY } from "apca-w3";
14import chroma from "chroma-js";
15import { catmullRom2bezier, prepareCurve } from "./curve";
16
17const 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', // named per correct color definition order
26 RGB: 'rgb',
27 OKLAB: 'oklab',
28 OKLCH: 'oklch'
29};
30
31function round(x, n = 0) {
32 const ten = 10 ** n;
33 return Math.round(x * ten) / ten;
34}
35
36function multiplyRatios(ratio, multiplier) {
37 let r;
38 // Normalize contrast ratios before multiplying by this._contrast
39 // by making 1 = 0. This ensures consistent application of increase/decrease
40 // in contrast ratios. Then add 1 back to number for contextual ratio value.
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
52function cArray(c) {
53 return chroma(String(c)).jch();
54}
55
56function hsluvArray(c) {
57 return chroma(String(c)).hsluv();
58}
59
60function 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 // leading NaNs
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 // all are grey case
84 if (nans.length) {
85 // hue is not important except for JCh
86 const safeJChHue = chroma('#ccc').jch()[2];
87 nans.forEach((j) => { point[j] = safeJChHue; });
88 }
89 nans.length = 0;
90 // trailing NaNs
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 // other NaNs
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 // force hue to go on the shortest route
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
146function 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
152function 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 // Set domain of each color key based on percentage (as HSLuv lightness)
175 // against the full scale of black to white
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 // Domains need to be a percentage of the available luminosity range
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 // Test logarithmic domain (for non-contrast-based scales)
197 let sqrtDomains = makePowScale(shift, [1, swatches], [1, swatches]);
198 sqrtDomains = domains.map((d) => Math.max(0, sqrtDomains(d)));
199
200 // Transform square root in order to smooth gradient
201 domains = sqrtDomains;
202 // if(distributeLightness === 'parabolic') {
203 // const parabola = (x) => {return (Math.sqrt(x, 2))}
204 // let percDomains = sqrtDomains.map((d) => {return d/swatches})
205 // let newDomains = percDomains.map((d) => {return parabola(d) * swatches})
206 // domains = newDomains;
207 // }
208 if(distributeLightness === 'polynomial') {
209 // Equation based on polynomial mapping of lightness values in CIECAM02
210 // of the RgBu diverging color scale.
211 // const polynomial = (x) => { return 2.53906249999454 * Math.pow(x,4) - 6.08506944443434 * Math.pow(x,3) + 5.11197916665992 * Math.pow(x,2) - 2.56537698412552 * x + 0.999702380952327; }
212 // const polynomial = (x) => { return Math.sqrt(Math.sqrt(x)) }
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 // Convert to HSLuv and keep track of original indices
222 .map((c, i) => ({ colorKeys: cArray(c), index: i }))
223 // Sort by lightness
224 .sort((c1, c2) => c2.colorKeys[0] - c1.colorKeys[0])
225 // Retrieve original RGB color
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 // special case for HCL if C is NaN we should treat it as 0
250 ColorsArray.forEach((c) => { c[1] = Number.isNaN(c[1]) ? 0 : c[1]; });
251 }
252 if (space === 'jch') {
253 // JCh has some “random” hue for grey colors.
254 // Replacing it to NaN, so we can apply the same method of dealing with them.
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 // const Colors = new Array(swatches).fill().map((_, d) => chroma(scale(d)).hex());
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
288function 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
300function uniq(a) {
301 return Array.from(new Set(a));
302}
303
304// Helper function to change any NaN to a zero
305function filterNaN(x) {
306 if (Number.isNaN(x)) {
307 return 0;
308 }
309 return x;
310}
311
312// Helper function for rounding color values to whole numbers
313function 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
379function 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
387function getContrast(color, base, baseV, method='wcag2') {
388 if(method === 'wcag2') {
389 if (baseV === undefined) { // If base is an array and 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); // will return value >=1 if color is darker than background
398 const cr2 = (baseLum + 0.05) / (colorLum + 0.05); // will return value >=1 if color is lighter than background
399
400 if (baseV < 0.5) { // Dark themes
401 // If color is darker than background, return cr1 which will be whole number
402 if (cr1 >= 1) {
403 return cr1;
404 }
405 // If color is lighter than background, return cr2 as negative whole number
406 return -cr2;
407 }
408 // Light themes
409 // If color is lighter than background, return cr2 which will be whole number
410 if (cr1 < 1) {
411 return cr2;
412 }
413 // If color is darker than background, return cr1 as negative whole number
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
425function 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
432function ratioName(r, formula) {
433 if (!r) { throw new Error('Ratios undefined'); }
434 r = r.sort((a, b) => a - b); // sort ratio array in case unordered
435
436 const min = minPositive(r, formula);
437 const minIndex = r.indexOf(min);
438 const nArr = []; // names array
439
440 const rNeg = r.slice(0, minIndex);
441 const rPos = r.slice(minIndex, r.length);
442
443 // Name the negative values
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 // Name the positive values
451 for (let i = 0; i < rPos.length; i++) {
452 nArr.push((i + 1) * 100);
453 }
454 nArr.sort((a, b) => a - b); // just for safe measure
455
456 return nArr;
457}
458
459const 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 // let ccounter = 0;
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 // ccounter++;
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
508export {
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};