UNPKG

12.6 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
13const chroma = require('chroma-js');
14const { catmullRom2bezier, prepareCurve } = require('./curve');
15
16const colorSpaces = {
17 CAM02: 'jab',
18 CAM02p: 'jch',
19 HEX: 'hex',
20 HSL: 'hsl',
21 HSLuv: 'hsluv',
22 HSV: 'hsv',
23 LAB: 'lab',
24 LCH: 'lch', // named per correct color definition order
25 RGB: 'rgb',
26};
27
28function round(x, n = 0) {
29 const ten = 10 ** n;
30 return Math.round(x * ten) / ten;
31}
32
33function multiplyRatios(ratio, multiplier) {
34 let r;
35 // Normalize contrast ratios before multiplying by this._contrast
36 // by making 1 = 0. This ensures consistent application of increase/decrease
37 // in contrast ratios. Then add 1 back to number for contextual ratio value.
38 if (ratio > 1) {
39 r = (ratio - 1) * multiplier + 1;
40 } else if (ratio < -1) {
41 r = (ratio + 1) * multiplier - 1;
42 } else {
43 r = 1;
44 }
45
46 return round(r, 2);
47}
48
49function cArray(c) {
50 return chroma(String(c)).hsluv();
51}
52
53function smoothScale(ColorsArray, domains, space) {
54 const points = [[], [], []];
55 ColorsArray.forEach((color, i) => points.forEach((point, j) => point.push(domains[i], color[j])));
56 if (space === 'hcl') {
57 const point = points[1];
58 for (let i = 1; i < point.length; i += 2) {
59 if (Number.isNaN(point[i])) {
60 point[i] = 0;
61 }
62 }
63 }
64 points.forEach((point) => {
65 const nans = [];
66 // leading NaNs
67 for (let i = 1; i < point.length; i += 2) {
68 if (Number.isNaN(point[i])) {
69 nans.push(i);
70 } else {
71 nans.forEach((j) => { point[j] = point[i]; });
72 nans.length = 0;
73 break;
74 }
75 }
76 // all are grey case
77 if (nans.length) {
78 // hue is not important except for JCh
79 const safeJChHue = chroma('#ccc').jch()[2];
80 nans.forEach((j) => { point[j] = safeJChHue; });
81 }
82 nans.length = 0;
83 // trailing NaNs
84 for (let i = point.length - 1; i > 0; i -= 2) {
85 if (Number.isNaN(point[i])) {
86 nans.push(i);
87 } else {
88 nans.forEach((j) => { point[j] = point[i]; });
89 break;
90 }
91 }
92 // other NaNs
93 for (let i = 1; i < point.length; i += 2) {
94 if (Number.isNaN(point[i])) {
95 point.splice(i - 1, 2);
96 i -= 2;
97 }
98 }
99 // force hue to go on the shortest route
100 if (space in { hcl: 1, hsl: 1, hsluv: 1, hsv: 1, jch: 1 }) {
101 let prev = point[1];
102 let addon = 0;
103 for (let i = 3; i < point.length; i += 2) {
104 const p = point[i] + addon;
105 const zero = Math.abs(prev - p);
106 const plus = Math.abs(prev - (p + 360));
107 const minus = Math.abs(prev - (p - 360));
108 if (plus < zero && plus < minus) {
109 addon += 360;
110 }
111 if (minus < zero && minus < plus) {
112 addon -= 360;
113 }
114 point[i] += addon;
115 prev = point[i];
116 }
117 }
118 });
119 const prep = points.map((point) => catmullRom2bezier(point).map((curve) => prepareCurve(...curve)));
120 return (d) => {
121 const ch = prep.map((p) => {
122 for (let i = 0; i < p.length; i++) {
123 const res = p[i](d);
124 if (res != null) {
125 return res;
126 }
127 }
128 return null;
129 });
130
131 if (space === 'jch' && ch[1] < 0) {
132 ch[1] = 0;
133 }
134
135 return chroma[space](...ch).hex();
136 };
137}
138
139function makePowScale(exp = 1, domains = [0, 1], range = [0, 1]) {
140 const m = (range[1] - range[0]) / (domains[1] ** exp - domains[0] ** exp);
141 const c = range[0] - m * domains[0] ** exp;
142 return (x) => m * x ** exp + c;
143}
144
145function createScale({
146 swatches,
147 colorKeys,
148 colorspace = 'LAB',
149 shift = 1,
150 fullScale = true,
151 smooth = false,
152 asFun = false,
153} = {}) {
154 const space = colorSpaces[colorspace];
155 if (!space) {
156 throw new Error(`Colorspace “${colorspace}” not supported`);
157 }
158 if (!colorKeys) {
159 throw new Error(`Colorkeys missing: returned “${colorKeys}”`);
160 }
161
162 let domains = colorKeys
163 .map((key) => swatches - swatches * (chroma(key).hsluv()[2] / 100))
164 .sort((a, b) => a - b)
165 .concat(swatches);
166
167 domains.unshift(0);
168
169 // Test logarithmic domain (for non-contrast-based scales)
170 let sqrtDomains = makePowScale(shift, [1, swatches], [1, swatches]);
171 sqrtDomains = domains.map((d) => Math.max(0, sqrtDomains(d)));
172
173 // Transform square root in order to smooth gradient
174 domains = sqrtDomains;
175
176 const sortedColor = colorKeys
177 // Convert to HSLuv and keep track of original indices
178 .map((c, i) => ({ colorKeys: cArray(c), index: i }))
179 // Sort by lightness
180 .sort((c1, c2) => c2.colorKeys[2] - c1.colorKeys[2])
181 // Retrieve original RGB color
182 .map((data) => colorKeys[data.index]);
183
184 let ColorsArray = [];
185
186 let scale;
187 if (fullScale) {
188 const white = space === 'lch' ? chroma.lch(...chroma('#fff').lch()) : '#fff';
189 const black = space === 'lch' ? chroma.lch(...chroma('#000').lch()) : '#000';
190 ColorsArray = [
191 white,
192 ...sortedColor,
193 black,
194 ];
195 } else {
196 ColorsArray = sortedColor;
197 }
198
199 if (smooth) {
200 const stringColors = ColorsArray;
201 ColorsArray = ColorsArray.map((d) => chroma(String(d))[space]());
202 if (space === 'hcl') {
203 // special case for HCL if C is NaN we should treat it as 0
204 ColorsArray.forEach((c) => { c[1] = Number.isNaN(c[1]) ? 0 : c[1]; });
205 }
206 if (space === 'jch') {
207 // JCh has some “random” hue for grey colors.
208 // Replacing it to NaN, so we can apply the same method of dealing with them.
209 for (let i = 0; i < stringColors.length; i++) {
210 const color = chroma(stringColors[i]).hcl();
211 if (Number.isNaN(color[0])) {
212 ColorsArray[i][2] = NaN;
213 }
214 }
215 }
216 scale = smoothScale(ColorsArray, domains, space);
217 } else {
218 scale = chroma.scale(ColorsArray.map((color) => {
219 if (typeof color === 'object' && color.constructor === chroma.Color) {
220 return color;
221 }
222 return String(color);
223 })).domain(domains).mode(space);
224 }
225 if (asFun) {
226 return scale;
227 }
228
229 const Colors = new Array(swatches).fill().map((_, d) => chroma(scale(d)).hex());
230
231 const colors = Colors.filter((el) => el != null);
232
233 return colors;
234}
235
236function removeDuplicates(originalArray, prop) {
237 const newArray = [];
238 const lookupObject = {};
239 const keys1 = Object.keys(originalArray);
240
241 keys1.forEach((i) => { lookupObject[originalArray[i][prop]] = originalArray[i]; });
242
243 const keys2 = Object.keys(lookupObject);
244 keys2.forEach((i) => newArray.push(lookupObject[i]));
245 return newArray;
246}
247
248function uniq(a) {
249 return Array.from(new Set(a));
250}
251
252// Helper function to change any NaN to a zero
253function filterNaN(x) {
254 if (Number.isNaN(x)) {
255 return 0;
256 }
257 return x;
258}
259
260// Helper function for rounding color values to whole numbers
261function convertColorValue(color, format, object = false) {
262 if (!color) {
263 throw new Error(`Cannot convert color value of “${color}”`);
264 }
265 if (!colorSpaces[format]) {
266 throw new Error(`Cannot convert to colorspace “${format}”`);
267 }
268 const space = colorSpaces[format];
269 const colorObj = chroma(String(color))[space]();
270 if (format === 'HSL') {
271 colorObj.pop();
272 }
273 if (format === 'HEX') {
274 if (object) {
275 const rgb = chroma(String(color)).rgb();
276 return { r: rgb[0], g: rgb[1], b: rgb[2] };
277 }
278 return colorObj;
279 }
280
281 const colorObject = {};
282 let newColorObj = colorObj.map(filterNaN);
283
284 newColorObj = newColorObj.map((ch, i) => {
285 let rnd = round(ch);
286 let j = i;
287 if (space === 'hsluv') {
288 j += 2;
289 }
290 let letter = space.charAt(j);
291 if (space === 'jch' && letter === 'c') {
292 letter = 'C';
293 }
294 colorObject[letter === 'j' ? 'J' : letter] = rnd;
295 if (space in { lab: 1, lch: 1, jab: 1, jch: 1 }) {
296 if (!object) {
297 if (letter === 'l' || letter === 'j') {
298 rnd += '%';
299 }
300 if (letter === 'h') {
301 rnd += 'deg';
302 }
303 }
304 } else if (space !== 'hsluv') {
305 if (letter === 's' || letter === 'l' || letter === 'v') {
306 colorObject[letter] = round(ch, 2);
307 if (!object) {
308 rnd = round(ch * 100);
309 rnd += '%';
310 }
311 } else if (letter === 'h' && !object) {
312 rnd += 'deg';
313 }
314 }
315 return rnd;
316 });
317
318 const stringName = space;
319 const stringValue = `${stringName}(${newColorObj.join(', ')})`;
320
321 if (object) {
322 return colorObject;
323 }
324 return stringValue;
325}
326
327function luminance(r, g, b) {
328 const a = [r, g, b].map((v) => {
329 v /= 255;
330 return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
331 });
332 return (a[0] * 0.2126) + (a[1] * 0.7152) + (a[2] * 0.0722);
333}
334
335function getContrast(color, base, baseV) {
336 if (baseV === undefined) { // If base is an array and baseV undefined
337 const baseLightness = chroma.rgb(...base).hsluv()[2];
338 baseV = round(baseLightness / 100, 2);
339 }
340
341 const colorLum = luminance(color[0], color[1], color[2]);
342 const baseLum = luminance(base[0], base[1], base[2]);
343
344 const cr1 = (colorLum + 0.05) / (baseLum + 0.05); // will return value >=1 if color is darker than background
345 const cr2 = (baseLum + 0.05) / (colorLum + 0.05); // will return value >=1 if color is lighter than background
346
347 if (baseV < 0.5) { // Dark themes
348 // If color is darker than background, return cr1 which will be whole number
349 if (cr1 >= 1) {
350 return cr1;
351 }
352 // If color is lighter than background, return cr2 as negative whole number
353 return -cr2;
354 }
355 // Light themes
356 // If color is lighter than background, return cr2 which will be whole number
357 if (cr1 < 1) {
358 return cr2;
359 }
360 // If color is darker than background, return cr1 as negative whole number
361 if (cr1 === 1) {
362 return cr1;
363 }
364 return -cr1;
365}
366
367function minPositive(r) {
368 if (!r) { throw new Error('Array undefined'); }
369 if (!Array.isArray(r)) { throw new Error('Passed object is not an array'); }
370 return Math.min(...r.filter((val) => val >= 1));
371}
372
373function ratioName(r) {
374 if (!r) { throw new Error('Ratios undefined'); }
375 r = r.sort((a, b) => a - b); // sort ratio array in case unordered
376
377 const min = minPositive(r);
378 const minIndex = r.indexOf(min);
379 const nArr = []; // names array
380
381 const rNeg = r.slice(0, minIndex);
382 const rPos = r.slice(minIndex, r.length);
383
384 // Name the negative values
385 for (let i = 0; i < rNeg.length; i++) {
386 const d = 1 / (rNeg.length + 1);
387 const m = d * 100;
388 const nVal = m * (i + 1);
389 nArr.push(round(nVal));
390 }
391 // Name the positive values
392 for (let i = 0; i < rPos.length; i++) {
393 nArr.push((i + 1) * 100);
394 }
395 nArr.sort((a, b) => a - b); // just for safe measure
396
397 return nArr;
398}
399
400const searchColors = (color, bgRgbArray, baseV, ratioValues) => {
401 const colorLen = 3000;
402 const colorScale = createScale({
403 swatches: colorLen,
404 colorKeys: color._colorKeys,
405 colorspace: color._colorspace,
406 shift: 1,
407 smooth: color._smooth,
408 asFun: true,
409 });
410 const ccache = {};
411 // let ccounter = 0;
412 const getContrast2 = (i) => {
413 if (ccache[i]) {
414 return ccache[i];
415 }
416 const rgb = chroma(colorScale(i)).rgb();
417 const c = getContrast(rgb, bgRgbArray, baseV);
418 ccache[i] = c;
419 // ccounter++;
420 return c;
421 };
422 const colorSearch = (x) => {
423 const first = getContrast2(0);
424 const last = getContrast2(colorLen);
425 const dir = first < last ? 1 : -1;
426 const ε = 0.01;
427 x += 0.005 * Math.sign(x);
428 let step = colorLen / 2;
429 let dot = step;
430 let val = getContrast2(dot);
431 let counter = 100;
432 while (Math.abs(val - x) > ε && counter) {
433 counter--;
434 step /= 2;
435 if (val < x) {
436 dot += step * dir;
437 } else {
438 dot -= step * dir;
439 }
440 val = getContrast2(dot);
441 }
442 return round(dot, 3);
443 };
444 const outputColors = [];
445 ratioValues.forEach((ratio) => outputColors.push(colorScale(colorSearch(+ratio))));
446 return outputColors;
447};
448
449module.exports = {
450 cArray,
451 colorSpaces,
452 convertColorValue,
453 createScale,
454 getContrast,
455 luminance,
456 minPositive,
457 multiplyRatios,
458 ratioName,
459 removeDuplicates,
460 round,
461 searchColors,
462 uniq,
463};