UNPKG

19.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 d3 = require('./d3.js');
14
15const { catmullRom2bezier, prepareCurve } = require('./curve.js');
16const { color } = require('./d3.js');
17
18function smoothScale(ColorsArray, domains, space) {
19 const points = space.channels.map(() => []);
20 ColorsArray.forEach((color, i) =>
21 points.forEach((point, j) =>
22 point.push(domains[i], color[space.channels[j]])
23 )
24 );
25 if (space.name == "hcl") {
26 const point = points[1];
27 for (let i = 1; i < point.length; i += 2) {
28 if (isNaN(point[i])) {
29 point[i] = 0;
30 }
31 }
32 }
33 points.forEach(point => {
34 const nans = [];
35 // leading NaNs
36 for (let i = 1; i < point.length; i += 2) {
37 if (isNaN(point[i])) {
38 nans.push(i);
39 } else {
40 nans.forEach(j => point[j] = point[i]);
41 nans.length = 0;
42 break;
43 }
44 }
45 // all are grey case
46 if (nans.length) {
47 // hue is not important except for JCh
48 const safeJChHue = d3.jch("#ccc").h;
49 nans.forEach(j => point[j] = safeJChHue);
50 }
51 nans.length = 0;
52 // trailing NaNs
53 for (let i = point.length - 1; i > 0; i -= 2) {
54 if (isNaN(point[i])) {
55 nans.push(i);
56 } else {
57 nans.forEach(j => point[j] = point[i]);
58 break;
59 }
60 }
61 // other NaNs
62 for (let i = 1; i < point.length; i += 2) {
63 if (isNaN(point[i])) {
64 point.splice(i - 1, 2);
65 i -= 2;
66 }
67 }
68 // force hue to go on the shortest route
69 if (space.name in {hcl: 1, hsl: 1, hsluv: 1, hsv: 1, jch: 1}) {
70 let prev = point[1];
71 let addon = 0;
72 for (let i = 3; i < point.length; i += 2) {
73 const p = point[i] + addon;
74 const zero = Math.abs(prev - p);
75 const plus = Math.abs(prev - (p + 360));
76 const minus = Math.abs(prev - (p - 360));
77 if (plus < zero && plus < minus) {
78 addon += 360;
79 }
80 if (minus < zero && minus < plus) {
81 addon -= 360;
82 }
83 point[i] += addon;
84 prev = point[i];
85 }
86 }
87 })
88 const prep = points.map(point =>
89 catmullRom2bezier(point).map(curve =>
90 prepareCurve(...curve)
91 )
92 );
93 return d => {
94 const ch = prep.map(p => {
95 for (let i = 0; i < p.length; i++) {
96 const res = p[i](d);
97 if (res != null) {
98 return res;
99 }
100 }
101 });
102
103 if (space.name == 'jch' && ch[1] < 0) {
104 ch[1] = 0;
105 }
106
107 return d3[space.name](...ch) + "";
108 };
109}
110
111const colorSpaces = {
112 CAM02: {
113 name: 'jab',
114 channels: ['J', 'a', 'b'],
115 interpolator: d3.interpolateJab,
116 function: d3.jab
117 },
118 CAM02p: {
119 name: 'jch',
120 channels: ['J', 'C', 'h'],
121 interpolator: d3.interpolateJch,
122 function: d3.jch
123 },
124 LCH: {
125 name: 'lch', // named per correct color definition order
126 channels: ['h', 'c', 'l'],
127 interpolator: d3.interpolateHcl,
128 white: d3.hcl(NaN, 0, 100),
129 black: d3.hcl(NaN, 0, 0),
130 function: d3.hcl
131 },
132 LAB: {
133 name: 'lab',
134 channels: ['l', 'a', 'b'],
135 interpolator: d3.interpolateLab,
136 function: d3.lab
137 },
138 HSL: {
139 name: 'hsl',
140 channels: ['h', 's', 'l'],
141 interpolator: d3.interpolateHsl,
142 function: d3.hsl
143 },
144 HSLuv: {
145 name: 'hsluv',
146 channels: ['l', 'u', 'v'],
147 interpolator: d3.interpolateHsluv,
148 white: d3.hsluv(NaN, NaN, 100),
149 black: d3.hsluv(NaN, NaN, 0),
150 function: d3.hsluv
151 },
152 RGB: {
153 name: 'rgb',
154 channels: ['r', 'g', 'b'],
155 interpolator: d3.interpolateRgb,
156 function: d3.rgb
157 },
158 HSV: {
159 name: 'hsv',
160 channels: ['h', 's', 'v'],
161 interpolator: d3.interpolateHsv,
162 function: d3.hsv
163 },
164 HEX: {
165 name: 'hex',
166 channels: ['r', 'g', 'b'],
167 interpolator: d3.interpolateRgb,
168 function: d3.rgb
169 }
170};
171
172function cArray(c) {
173 const color = d3.hsluv(c);
174 const L = color.l;
175 const U = color.u;
176 const V = color.v;
177
178 return [L, U, V];
179}
180
181function removeDuplicates(originalArray, prop) {
182 var newArray = [];
183 var lookupObject = {};
184
185 for(var i in originalArray) {
186 lookupObject[originalArray[i][prop]] = originalArray[i];
187 }
188
189 for(i in lookupObject) {
190 newArray.push(lookupObject[i]);
191 }
192 return newArray;
193}
194
195function createScale({
196 swatches,
197 colorKeys,
198 colorspace = 'LAB',
199 shift = 1,
200 fullScale = true,
201 smooth = false
202} = {}) {
203 const space = colorSpaces[colorspace];
204 if (!space) {
205 throw new Error(`Colorspace “${colorspace}” not supported`);
206 }
207
208 let domains = colorKeys
209 .map(key => swatches - swatches * (d3.hsluv(key).v / 100))
210 .sort((a, b) => a - b)
211 .concat(swatches);
212
213 domains.unshift(0);
214
215 // Test logarithmic domain (for non-contrast-based scales)
216 let sqrtDomains = d3.scalePow()
217 .exponent(shift)
218 .domain([1, swatches])
219 .range([1, swatches]);
220
221 sqrtDomains = domains.map((d) => {
222 if (sqrtDomains(d) < 0) {
223 return 0;
224 }
225 return sqrtDomains(d);
226 });
227
228 // Transform square root in order to smooth gradient
229 domains = sqrtDomains;
230
231 let sortedColor = colorKeys
232 // Convert to HSLuv and keep track of original indices
233 .map((c, i) => { return { colorKeys: cArray(c), index: i } })
234 // Sort by lightness
235 .sort((c1, c2) => c2.colorKeys[2] - c1.colorKeys[2])
236 // Retrieve original RGB color
237 .map(data => colorKeys[data.index]);
238
239 let inverseSortedColor = colorKeys
240 // Convert to HSLuv and keep track of original indices
241 .map((c, i) => { return {colorKeys: cArray(c), index: i} })
242 // Sort by lightness
243 .sort((c1, c2) => c1.colorKeys[2] - c2.colorKeys[2])
244 // Retrieve original RGB color
245 .map(data => colorKeys[data.index]);
246
247 let ColorsArray = [];
248
249 let scale;
250 if (fullScale) {
251 ColorsArray = [space.white || '#fff', ...sortedColor, space.black || '#000'];
252 } else {
253 ColorsArray = sortedColor;
254 }
255 const stringColors = ColorsArray;
256 ColorsArray = ColorsArray.map(d => d3[space.name](d));
257 if (space.name == 'hcl') {
258 // special case for HCL if C is NaN we should treat it as 0
259 ColorsArray.forEach(c => c.c = isNaN(c.c) ? 0 : c.c);
260 }
261 if (space.name == 'jch') {
262 // JCh has some “random” hue for grey colors.
263 // Replacing it to NaN, so we can apply the same method of dealing with them.
264 for (let i = 0; i < stringColors.length; i++) {
265 const color = d3.hcl(stringColors[i]);
266 if (!color.c) {
267 ColorsArray[i].h = NaN;
268 }
269 }
270 }
271
272 if (smooth) {
273 scale = smoothScale(ColorsArray, domains, space);
274 } else {
275 scale = d3.scaleLinear()
276 .range(ColorsArray)
277 .domain(domains)
278 .interpolate(space.interpolator);
279 }
280
281 let Colors = d3.range(swatches).map(d => scale(d));
282
283 let colors = Colors.filter(el => el != null);
284
285 // Return colors as hex values for interpolators.
286 let colorsHex = [];
287 for (let i = 0; i < colors.length; i++) {
288 colorsHex.push(d3.rgb(colors[i]).formatHex());
289 }
290
291 return {
292 colorKeys: colorKeys,
293 colorspace: colorspace,
294 shift: shift,
295 colors: colors,
296 scale: scale,
297 colorsHex: colorsHex
298 };
299}
300
301function generateBaseScale({
302 colorKeys,
303 colorspace = 'LAB',
304 smooth
305} = {}) {
306 // create massive scale
307 let swatches = 1000;
308 let scale = createScale({swatches: swatches, colorKeys: colorKeys, colorspace: colorspace, shift: 1, smooth: smooth});
309 let newColors = scale.colorsHex;
310
311 let colorObj = newColors
312 // Convert to HSLuv and keep track of original indices
313 .map((c, i) => { return { value: Math.round(cArray(c)[2]), index: i } });
314
315 let filteredArr = removeDuplicates(colorObj, "value")
316 .map(data => newColors[data.index]);
317
318 return filteredArr;
319}
320
321function generateContrastColors({
322 colorKeys,
323 base,
324 ratios,
325 colorspace = 'LAB',
326 smooth = false,
327 output = 'HEX'
328} = {}) {
329 if (!base) {
330 throw new Error(`Base is undefined`);
331 }
332 if (!colorKeys) {
333 throw new Error(`Color Keys are undefined`);
334 }
335 for (let i=0; i<colorKeys.length; i++) {
336 if (colorKeys[i].length < 6) {
337 throw new Error('Color Key must be greater than 6 and include hash # if hex.');
338 }
339 else if (colorKeys[i].length == 6 && colorKeys[i].charAt(0) != 0) {
340 throw new Error('Color Key missing hash #');
341 }
342 }
343 if (!ratios) {
344 throw new Error(`Ratios are undefined`);
345 }
346 const outputFormat = colorSpaces[output];
347 if (!outputFormat) {
348 throw new Error(`Colorspace “${output}” not supported`);
349 }
350
351 let swatches = 3000;
352
353 let scaleData = createScale({swatches: swatches, colorKeys: colorKeys, colorspace: colorspace, shift: 1, smooth: smooth});
354 let baseV = (d3.hsluv(base).v) / 100;
355
356 let Contrasts = d3.range(swatches).map((d) => {
357 let rgbArray = [d3.rgb(scaleData.scale(d)).r, d3.rgb(scaleData.scale(d)).g, d3.rgb(scaleData.scale(d)).b];
358 let baseRgbArray = [d3.rgb(base).r, d3.rgb(base).g, d3.rgb(base).b];
359 let ca = contrast(rgbArray, baseRgbArray, baseV).toFixed(2);
360
361 return Number(ca);
362 });
363
364 let contrasts = Contrasts.filter(el => el != null);
365
366 let newColors = [];
367 ratios = ratios.map(Number);
368
369 // Return color matching target ratio, or closest number
370 for (let i=0; i < ratios.length; i++){
371 let r = binarySearch(contrasts, ratios[i], baseV);
372
373 // use fixColorValue function to convert each color to the specified
374 // output format.
375 newColors.push(fixColorValue(scaleData.colors[r], output));
376
377 }
378
379 return newColors;
380}
381
382// Helper function to change any NaN to a zero
383function filterNaN(x) {
384 if(isNaN(x)) {
385 return 0;
386 } else {
387 return x;
388 }
389}
390
391// Helper function for rounding color values to whole numbers
392function fixColorValue(color, format, object = false) {
393 let colorObj = colorSpaces[format].function(color);
394 let propArray = colorSpaces[format].channels;
395
396 let newColorObj = {
397 [propArray[0]]: filterNaN(colorObj[propArray[0]]),
398 [propArray[1]]: filterNaN(colorObj[propArray[1]]),
399 [propArray[2]]: filterNaN(colorObj[propArray[2]])
400 }
401
402 // HSLuv
403 if (format === "HSLuv") {
404 for (let i = 0; i < propArray.length; i++) {
405
406 let roundedPct = Math.round(newColorObj[propArray[i]]);
407 newColorObj[propArray[i]] = roundedPct;
408 }
409 }
410 // LAB, LCH, JAB, JCH
411 else if (format === "LAB" || format === "LCH" || format === "CAM02" || format === "CAM02p") {
412 for (let i = 0; i < propArray.length; i++) {
413 let roundedPct = Math.round(newColorObj[propArray[i]]);
414
415 if (propArray[i] === "h" && !object) {
416 roundedPct = roundedPct + "deg";
417 }
418 if (propArray[i] === "l" && !object || propArray[i] === "J" && !object) {
419 roundedPct = roundedPct + "%";
420 }
421
422 newColorObj[propArray[i]] = roundedPct;
423
424 }
425 }
426 else {
427 for (let i = 0; i < propArray.length; i++) {
428 if (propArray[i] === "s" || propArray[i] === "l" || propArray[i] === "v") {
429 // leave as decimal format
430 let roundedPct = parseFloat(newColorObj[propArray[i]].toFixed(2));
431 if(object) {
432 newColorObj[propArray[i]] = roundedPct;
433 }
434 else {
435 newColorObj[propArray[i]] = Math.round(roundedPct * 100) + "%";
436 }
437 }
438 else {
439 let roundedPct = parseFloat(newColorObj[propArray[i]].toFixed());
440 if (propArray[i] === "h" && !object) {
441 roundedPct = roundedPct + "deg";
442 }
443 newColorObj[propArray[i]] = roundedPct;
444 }
445 }
446 }
447
448 let stringName = colorSpaces[format].name;
449 let stringValue;
450
451 if (format === "HEX") {
452 stringValue = d3.rgb(color).formatHex();
453 } else {
454 let str0, srt1, str2;
455 if (format === "LCH") {
456 // Have to force opposite direction of array index for LCH
457 // because d3 defines the channel order as "h, c, l" but we
458 // want the output to be in the correct format
459 str0 = newColorObj[propArray[2]] + ", ";
460 str1 = newColorObj[propArray[1]] + ", ";
461 str2 = newColorObj[propArray[0]];
462 }
463 else {
464 str0 = newColorObj[propArray[0]] + ", ";
465 str1 = newColorObj[propArray[1]] + ", ";
466 str2 = newColorObj[propArray[2]];
467 }
468
469 stringValue = stringName + "(" + str0 + str1 + str2 + ")";
470 }
471
472 if (object) {
473 // return colorObj;
474 return newColorObj;
475 } else {
476 return stringValue;
477 }
478}
479
480function luminance(r, g, b) {
481 let a = [r, g, b].map((v) => {
482 v /= 255;
483 return v <= 0.03928
484 ? v / 12.92
485 : Math.pow( (v + 0.055) / 1.055, 2.4 );
486 });
487 return (a[0] * 0.2126) + (a[1] * 0.7152) + (a[2] * 0.0722);
488}
489
490function contrast(color, base, baseV) {
491 let colorLum = luminance(color[0], color[1], color[2]);
492 let baseLum = luminance(base[0], base[1], base[2]);
493
494 let cr1 = (colorLum + 0.05) / (baseLum + 0.05);
495 let cr2 = (baseLum + 0.05) / (colorLum + 0.05);
496
497 if (baseV < 0.5) {
498 if (cr1 >= 1) {
499 return cr1;
500 }
501 else {
502 return cr2 * -1;
503 } // Return as whole negative number
504 }
505 else {
506 if (cr1 < 1) {
507 return cr2;
508 }
509 else {
510 return cr1 * -1;
511 } // Return as whole negative number
512 }
513}
514
515function minPositive(r) {
516 if (!r) { throw new Error('Array undefined');}
517 if (!Array.isArray(r)) { throw new Error('Passed object is not an array');}
518 let arr = [];
519
520 for(let i=0; i < r.length; i++) {
521 if(r[i] >= 1) {
522 arr.push(r[i]);
523 }
524 }
525 return Math.min(...arr);
526}
527
528function ratioName(r) {
529 if (!r) { throw new Error('Ratios undefined');}
530 r = r.sort(function(a, b){return a - b}); // sort ratio array in case unordered
531
532 let min = minPositive(r);
533 let minIndex = r.indexOf(min);
534 let nArr = []; // names array
535
536 let rNeg = r.slice(0, minIndex);
537 let rPos = r.slice(minIndex, r.length);
538
539 // Name the negative values
540 for (let i=0; i < rNeg.length; i++) {
541 let d = 1/(rNeg.length + 1);
542 let m = d * 100;
543 let nVal = m * (i + 1);
544 nArr.push(Number(nVal.toFixed()));
545 }
546 // Name the positive values
547 for (let i=0; i < rPos.length; i++) {
548 nArr.push((i+1)*100);
549 }
550 nArr.sort(function(a, b){return a - b}); // just for safe measure
551
552 return nArr;
553}
554
555function generateAdaptiveTheme({
556 colorScales,
557 baseScale,
558 brightness,
559 contrast = 1,
560 output = 'HEX'
561}) {
562 if (!baseScale) {
563 throw new Error('baseScale is undefined');
564 }
565 let found = false;
566 for(let i = 0; i < colorScales.length; i++) {
567 if (colorScales[i].name !== baseScale) {
568 found = true;
569 }
570 }
571 if (found = false) {
572 throw new Error('baseScale must match the name of a colorScales object');
573 }
574
575 if (!colorScales) {
576 throw new Error('colorScales are undefined');
577 }
578 if (!Array.isArray(colorScales)) {
579 throw new Error('colorScales must be an array of objects');
580 }
581 for (let i=0; i < colorScales.length; i ++) {
582 // if (colorScales[i].swatchNames) { // if the scale has custom swatch names
583 // let ratioLength = colorScales[i].ratios.length;
584 // let swatchNamesLength = colorScales[i].swatchNames.length;
585
586 // if (ratioLength !== swatchNamesLength) {
587 // throw new Error('`${colorScales[i].name}`ratios and swatchNames must be equal length')
588 // }
589 // }
590 }
591
592 if (brightness === undefined) {
593 return function(brightness, contrast) {
594 return generateAdaptiveTheme({baseScale: baseScale, colorScales: colorScales, brightness: brightness, contrast: contrast, output: output});
595 }
596 }
597 else {
598 // Find color object matching base scale
599 let baseIndex = colorScales.findIndex( x => x.name === baseScale );
600 let baseKeys = colorScales[baseIndex].colorKeys;
601 let baseMode = colorScales[baseIndex].colorspace;
602 let smooth = colorScales[baseIndex].smooth;
603
604 // define params to pass as bscale
605 let bscale = generateBaseScale({colorKeys: baseKeys, colorspace: baseMode, smooth: smooth}); // base parameter to create base scale (0-100)
606 let bval = bscale[brightness];
607 let baseObj = {
608 background: bval
609 };
610
611 let arr = [];
612 arr.push(baseObj);
613
614 for (let i = 0; i < colorScales.length; i++) {
615 if (!colorScales[i].name) {
616 throw new Error('Color missing name');
617 }
618 let name = colorScales[i].name;
619
620 let ratioInput = colorScales[i].ratios;
621 let ratios;
622 let swatchNames;
623 // assign ratios array whether input is array or object
624 if(Array.isArray(ratioInput)) {
625 ratios = ratioInput;
626 } else {
627 ratios = Object.values(ratioInput);
628 swatchNames = Object.keys(ratioInput);
629 }
630
631 let smooth = colorScales[i].smooth;
632 let newArr = [];
633 let colorObj = {
634 name: name,
635 values: newArr
636 };
637
638 ratios = ratios.map(function(d) {
639 let r;
640 if(d > 1) {
641 r = ((d-1) * contrast) + 1;
642 }
643 else if(d < -1) {
644 r = ((d+1) * contrast) - 1;
645 }
646 else {
647 r = 1;
648 }
649 return Number(r.toFixed(2));
650 });
651
652 let outputColors = generateContrastColors({
653 colorKeys: colorScales[i].colorKeys,
654 colorspace: colorScales[i].colorspace,
655 ratios: ratios,
656 base: bval,
657 smooth: smooth,
658 output: output
659 });
660
661 for (let i=0; i < outputColors.length; i++) {
662 let n;
663 if(!swatchNames) {
664 let rVal = ratioName(ratios)[i];
665 n = name.concat(rVal);
666 }
667 else {
668 n = swatchNames[i];
669 }
670
671 let obj = {
672 name: n,
673 contrast: ratios[i],
674 value: outputColors[i]
675 };
676 newArr.push(obj)
677 }
678 arr.push(colorObj);
679
680 }
681
682 return arr;
683 }
684}
685
686// Binary search to find index of contrast ratio that is input
687// Modified from https://medium.com/hackernoon/programming-with-js-binary-search-aaf86cef9cb3
688function binarySearch(list, value, baseLum) {
689 // initial values for start, middle and end
690 let start = 0
691 let stop = list.length - 1
692 let middle = Math.floor((start + stop) / 2)
693
694 let minContrast = Math.min(...list);
695 let maxContrast = Math.max(...list);
696
697 // While the middle is not what we're looking for and the list does not have a single item
698 while (list[middle] !== value && start < stop) {
699 // Value greater than since array is ordered descending
700 if (baseLum > 0.5) { // if base is light, ratios ordered ascending
701 if (value < list[middle]) {
702 stop = middle - 1
703 }
704 else {
705 start = middle + 1
706 }
707 }
708 else { // order descending
709 if (value > list[middle]) {
710 stop = middle - 1
711 }
712 else {
713 start = middle + 1
714 }
715 }
716 // recalculate middle on every iteration
717 middle = Math.floor((start + stop) / 2)
718 }
719
720 // If no match, find closest item greater than value
721 let closest = list.reduce((prev, curr) => curr > value ? curr : prev);
722
723 // if the current middle item is what we're looking for return it's index, else closest
724 return (list[middle] == !value) ? closest : middle // how it was originally expressed
725}
726
727module.exports = {
728 createScale,
729 luminance,
730 contrast,
731 binarySearch,
732 generateBaseScale,
733 generateContrastColors,
734 minPositive,
735 ratioName,
736 generateAdaptiveTheme,
737 fixColorValue
738};