UNPKG

@adobe/leonardo-contrast-colors

Version:

Generate colors based on a desired contrast ratio

739 lines (652 loc) 19.6 kB
/* Copyright 2019 Adobe. All rights reserved. This file is licensed to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ const d3 = require('./d3.js'); const { catmullRom2bezier, prepareCurve } = require('./curve.js'); const { color } = require('./d3.js'); function smoothScale(ColorsArray, domains, space) { const points = space.channels.map(() => []); ColorsArray.forEach((color, i) => points.forEach((point, j) => point.push(domains[i], color[space.channels[j]]) ) ); if (space.name == "hcl") { const point = points[1]; for (let i = 1; i < point.length; i += 2) { if (isNaN(point[i])) { point[i] = 0; } } } points.forEach(point => { const nans = []; // leading NaNs for (let i = 1; i < point.length; i += 2) { if (isNaN(point[i])) { nans.push(i); } else { nans.forEach(j => point[j] = point[i]); nans.length = 0; break; } } // all are grey case if (nans.length) { // hue is not important except for JCh const safeJChHue = d3.jch("#ccc").h; nans.forEach(j => point[j] = safeJChHue); } nans.length = 0; // trailing NaNs for (let i = point.length - 1; i > 0; i -= 2) { if (isNaN(point[i])) { nans.push(i); } else { nans.forEach(j => point[j] = point[i]); break; } } // other NaNs for (let i = 1; i < point.length; i += 2) { if (isNaN(point[i])) { point.splice(i - 1, 2); i -= 2; } } // force hue to go on the shortest route if (space.name in {hcl: 1, hsl: 1, hsluv: 1, hsv: 1, jch: 1}) { let prev = point[1]; let addon = 0; for (let i = 3; i < point.length; i += 2) { const p = point[i] + addon; const zero = Math.abs(prev - p); const plus = Math.abs(prev - (p + 360)); const minus = Math.abs(prev - (p - 360)); if (plus < zero && plus < minus) { addon += 360; } if (minus < zero && minus < plus) { addon -= 360; } point[i] += addon; prev = point[i]; } } }) const prep = points.map(point => catmullRom2bezier(point).map(curve => prepareCurve(...curve) ) ); return d => { const ch = prep.map(p => { for (let i = 0; i < p.length; i++) { const res = p[i](d); if (res != null) { return res; } } }); if (space.name == 'jch' && ch[1] < 0) { ch[1] = 0; } return d3[space.name](...ch) + ""; }; } const colorSpaces = { CAM02: { name: 'jab', channels: ['J', 'a', 'b'], interpolator: d3.interpolateJab, function: d3.jab }, CAM02p: { name: 'jch', channels: ['J', 'C', 'h'], interpolator: d3.interpolateJch, function: d3.jch }, LCH: { name: 'lch', // named per correct color definition order channels: ['h', 'c', 'l'], interpolator: d3.interpolateHcl, white: d3.hcl(NaN, 0, 100), black: d3.hcl(NaN, 0, 0), function: d3.hcl }, LAB: { name: 'lab', channels: ['l', 'a', 'b'], interpolator: d3.interpolateLab, function: d3.lab }, HSL: { name: 'hsl', channels: ['h', 's', 'l'], interpolator: d3.interpolateHsl, function: d3.hsl }, HSLuv: { name: 'hsluv', channels: ['l', 'u', 'v'], interpolator: d3.interpolateHsluv, white: d3.hsluv(NaN, NaN, 100), black: d3.hsluv(NaN, NaN, 0), function: d3.hsluv }, RGB: { name: 'rgb', channels: ['r', 'g', 'b'], interpolator: d3.interpolateRgb, function: d3.rgb }, HSV: { name: 'hsv', channels: ['h', 's', 'v'], interpolator: d3.interpolateHsv, function: d3.hsv }, HEX: { name: 'hex', channels: ['r', 'g', 'b'], interpolator: d3.interpolateRgb, function: d3.rgb } }; function cArray(c) { const color = d3.hsluv(c); const L = color.l; const U = color.u; const V = color.v; return [L, U, V]; } function removeDuplicates(originalArray, prop) { var newArray = []; var lookupObject = {}; for(var i in originalArray) { lookupObject[originalArray[i][prop]] = originalArray[i]; } for(i in lookupObject) { newArray.push(lookupObject[i]); } return newArray; } function createScale({ swatches, colorKeys, colorspace = 'LAB', shift = 1, fullScale = true, smooth = false } = {}) { const space = colorSpaces[colorspace]; if (!space) { throw new Error(`Colorspace “${colorspace}” not supported`); } let domains = colorKeys .map(key => swatches - swatches * (d3.hsluv(key).v / 100)) .sort((a, b) => a - b) .concat(swatches); domains.unshift(0); // Test logarithmic domain (for non-contrast-based scales) let sqrtDomains = d3.scalePow() .exponent(shift) .domain([1, swatches]) .range([1, swatches]); sqrtDomains = domains.map((d) => { if (sqrtDomains(d) < 0) { return 0; } return sqrtDomains(d); }); // Transform square root in order to smooth gradient domains = sqrtDomains; let sortedColor = colorKeys // Convert to HSLuv and keep track of original indices .map((c, i) => { return { colorKeys: cArray(c), index: i } }) // Sort by lightness .sort((c1, c2) => c2.colorKeys[2] - c1.colorKeys[2]) // Retrieve original RGB color .map(data => colorKeys[data.index]); let inverseSortedColor = colorKeys // Convert to HSLuv and keep track of original indices .map((c, i) => { return {colorKeys: cArray(c), index: i} }) // Sort by lightness .sort((c1, c2) => c1.colorKeys[2] - c2.colorKeys[2]) // Retrieve original RGB color .map(data => colorKeys[data.index]); let ColorsArray = []; let scale; if (fullScale) { ColorsArray = [space.white || '#fff', ...sortedColor, space.black || '#000']; } else { ColorsArray = sortedColor; } const stringColors = ColorsArray; ColorsArray = ColorsArray.map(d => d3[space.name](d)); if (space.name == 'hcl') { // special case for HCL if C is NaN we should treat it as 0 ColorsArray.forEach(c => c.c = isNaN(c.c) ? 0 : c.c); } if (space.name == 'jch') { // JCh has some “random” hue for grey colors. // Replacing it to NaN, so we can apply the same method of dealing with them. for (let i = 0; i < stringColors.length; i++) { const color = d3.hcl(stringColors[i]); if (!color.c) { ColorsArray[i].h = NaN; } } } if (smooth) { scale = smoothScale(ColorsArray, domains, space); } else { scale = d3.scaleLinear() .range(ColorsArray) .domain(domains) .interpolate(space.interpolator); } let Colors = d3.range(swatches).map(d => scale(d)); let colors = Colors.filter(el => el != null); // Return colors as hex values for interpolators. let colorsHex = []; for (let i = 0; i < colors.length; i++) { colorsHex.push(d3.rgb(colors[i]).formatHex()); } return { colorKeys: colorKeys, colorspace: colorspace, shift: shift, colors: colors, scale: scale, colorsHex: colorsHex }; } function generateBaseScale({ colorKeys, colorspace = 'LAB', smooth } = {}) { // create massive scale let swatches = 1000; let scale = createScale({swatches: swatches, colorKeys: colorKeys, colorspace: colorspace, shift: 1, smooth: smooth}); let newColors = scale.colorsHex; let colorObj = newColors // Convert to HSLuv and keep track of original indices .map((c, i) => { return { value: Math.round(cArray(c)[2]), index: i } }); let filteredArr = removeDuplicates(colorObj, "value") .map(data => newColors[data.index]); return filteredArr; } function generateContrastColors({ colorKeys, base, ratios, colorspace = 'LAB', smooth = false, output = 'HEX' } = {}) { if (!base) { throw new Error(`Base is undefined`); } if (!colorKeys) { throw new Error(`Color Keys are undefined`); } for (let i=0; i<colorKeys.length; i++) { if (colorKeys[i].length < 6) { throw new Error('Color Key must be greater than 6 and include hash # if hex.'); } else if (colorKeys[i].length == 6 && colorKeys[i].charAt(0) != 0) { throw new Error('Color Key missing hash #'); } } if (!ratios) { throw new Error(`Ratios are undefined`); } const outputFormat = colorSpaces[output]; if (!outputFormat) { throw new Error(`Colorspace “${output}” not supported`); } let swatches = 3000; let scaleData = createScale({swatches: swatches, colorKeys: colorKeys, colorspace: colorspace, shift: 1, smooth: smooth}); let baseV = (d3.hsluv(base).v) / 100; let Contrasts = d3.range(swatches).map((d) => { let rgbArray = [d3.rgb(scaleData.scale(d)).r, d3.rgb(scaleData.scale(d)).g, d3.rgb(scaleData.scale(d)).b]; let baseRgbArray = [d3.rgb(base).r, d3.rgb(base).g, d3.rgb(base).b]; let ca = contrast(rgbArray, baseRgbArray, baseV).toFixed(2); return Number(ca); }); let contrasts = Contrasts.filter(el => el != null); let newColors = []; ratios = ratios.map(Number); // Return color matching target ratio, or closest number for (let i=0; i < ratios.length; i++){ let r = binarySearch(contrasts, ratios[i], baseV); // use fixColorValue function to convert each color to the specified // output format. newColors.push(fixColorValue(scaleData.colors[r], output)); } return newColors; } // Helper function to change any NaN to a zero function filterNaN(x) { if(isNaN(x)) { return 0; } else { return x; } } // Helper function for rounding color values to whole numbers function fixColorValue(color, format, object = false) { let colorObj = colorSpaces[format].function(color); let propArray = colorSpaces[format].channels; let newColorObj = { [propArray[0]]: filterNaN(colorObj[propArray[0]]), [propArray[1]]: filterNaN(colorObj[propArray[1]]), [propArray[2]]: filterNaN(colorObj[propArray[2]]) } // HSLuv if (format === "HSLuv") { for (let i = 0; i < propArray.length; i++) { let roundedPct = Math.round(newColorObj[propArray[i]]); newColorObj[propArray[i]] = roundedPct; } } // LAB, LCH, JAB, JCH else if (format === "LAB" || format === "LCH" || format === "CAM02" || format === "CAM02p") { for (let i = 0; i < propArray.length; i++) { let roundedPct = Math.round(newColorObj[propArray[i]]); if (propArray[i] === "h" && !object) { roundedPct = roundedPct + "deg"; } if (propArray[i] === "l" && !object || propArray[i] === "J" && !object) { roundedPct = roundedPct + "%"; } newColorObj[propArray[i]] = roundedPct; } } else { for (let i = 0; i < propArray.length; i++) { if (propArray[i] === "s" || propArray[i] === "l" || propArray[i] === "v") { // leave as decimal format let roundedPct = parseFloat(newColorObj[propArray[i]].toFixed(2)); if(object) { newColorObj[propArray[i]] = roundedPct; } else { newColorObj[propArray[i]] = Math.round(roundedPct * 100) + "%"; } } else { let roundedPct = parseFloat(newColorObj[propArray[i]].toFixed()); if (propArray[i] === "h" && !object) { roundedPct = roundedPct + "deg"; } newColorObj[propArray[i]] = roundedPct; } } } let stringName = colorSpaces[format].name; let stringValue; if (format === "HEX") { stringValue = d3.rgb(color).formatHex(); } else { let str0, srt1, str2; if (format === "LCH") { // Have to force opposite direction of array index for LCH // because d3 defines the channel order as "h, c, l" but we // want the output to be in the correct format str0 = newColorObj[propArray[2]] + ", "; str1 = newColorObj[propArray[1]] + ", "; str2 = newColorObj[propArray[0]]; } else { str0 = newColorObj[propArray[0]] + ", "; str1 = newColorObj[propArray[1]] + ", "; str2 = newColorObj[propArray[2]]; } stringValue = stringName + "(" + str0 + str1 + str2 + ")"; } if (object) { // return colorObj; return newColorObj; } else { return stringValue; } } function luminance(r, g, b) { let a = [r, g, b].map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow( (v + 0.055) / 1.055, 2.4 ); }); return (a[0] * 0.2126) + (a[1] * 0.7152) + (a[2] * 0.0722); } function contrast(color, base, baseV) { let colorLum = luminance(color[0], color[1], color[2]); let baseLum = luminance(base[0], base[1], base[2]); let cr1 = (colorLum + 0.05) / (baseLum + 0.05); let cr2 = (baseLum + 0.05) / (colorLum + 0.05); if (baseV < 0.5) { if (cr1 >= 1) { return cr1; } else { return cr2 * -1; } // Return as whole negative number } else { if (cr1 < 1) { return cr2; } else { return cr1 * -1; } // Return as whole negative number } } function minPositive(r) { if (!r) { throw new Error('Array undefined');} if (!Array.isArray(r)) { throw new Error('Passed object is not an array');} let arr = []; for(let i=0; i < r.length; i++) { if(r[i] >= 1) { arr.push(r[i]); } } return Math.min(...arr); } function ratioName(r) { if (!r) { throw new Error('Ratios undefined');} r = r.sort(function(a, b){return a - b}); // sort ratio array in case unordered let min = minPositive(r); let minIndex = r.indexOf(min); let nArr = []; // names array let rNeg = r.slice(0, minIndex); let rPos = r.slice(minIndex, r.length); // Name the negative values for (let i=0; i < rNeg.length; i++) { let d = 1/(rNeg.length + 1); let m = d * 100; let nVal = m * (i + 1); nArr.push(Number(nVal.toFixed())); } // Name the positive values for (let i=0; i < rPos.length; i++) { nArr.push((i+1)*100); } nArr.sort(function(a, b){return a - b}); // just for safe measure return nArr; } function generateAdaptiveTheme({ colorScales, baseScale, brightness, contrast = 1, output = 'HEX' }) { if (!baseScale) { throw new Error('baseScale is undefined'); } let found = false; for(let i = 0; i < colorScales.length; i++) { if (colorScales[i].name !== baseScale) { found = true; } } if (found = false) { throw new Error('baseScale must match the name of a colorScales object'); } if (!colorScales) { throw new Error('colorScales are undefined'); } if (!Array.isArray(colorScales)) { throw new Error('colorScales must be an array of objects'); } for (let i=0; i < colorScales.length; i ++) { // if (colorScales[i].swatchNames) { // if the scale has custom swatch names // let ratioLength = colorScales[i].ratios.length; // let swatchNamesLength = colorScales[i].swatchNames.length; // if (ratioLength !== swatchNamesLength) { // throw new Error('`${colorScales[i].name}`ratios and swatchNames must be equal length') // } // } } if (brightness === undefined) { return function(brightness, contrast) { return generateAdaptiveTheme({baseScale: baseScale, colorScales: colorScales, brightness: brightness, contrast: contrast, output: output}); } } else { // Find color object matching base scale let baseIndex = colorScales.findIndex( x => x.name === baseScale ); let baseKeys = colorScales[baseIndex].colorKeys; let baseMode = colorScales[baseIndex].colorspace; let smooth = colorScales[baseIndex].smooth; // define params to pass as bscale let bscale = generateBaseScale({colorKeys: baseKeys, colorspace: baseMode, smooth: smooth}); // base parameter to create base scale (0-100) let bval = bscale[brightness]; let baseObj = { background: bval }; let arr = []; arr.push(baseObj); for (let i = 0; i < colorScales.length; i++) { if (!colorScales[i].name) { throw new Error('Color missing name'); } let name = colorScales[i].name; let ratioInput = colorScales[i].ratios; let ratios; let swatchNames; // assign ratios array whether input is array or object if(Array.isArray(ratioInput)) { ratios = ratioInput; } else { ratios = Object.values(ratioInput); swatchNames = Object.keys(ratioInput); } let smooth = colorScales[i].smooth; let newArr = []; let colorObj = { name: name, values: newArr }; ratios = ratios.map(function(d) { let r; if(d > 1) { r = ((d-1) * contrast) + 1; } else if(d < -1) { r = ((d+1) * contrast) - 1; } else { r = 1; } return Number(r.toFixed(2)); }); let outputColors = generateContrastColors({ colorKeys: colorScales[i].colorKeys, colorspace: colorScales[i].colorspace, ratios: ratios, base: bval, smooth: smooth, output: output }); for (let i=0; i < outputColors.length; i++) { let n; if(!swatchNames) { let rVal = ratioName(ratios)[i]; n = name.concat(rVal); } else { n = swatchNames[i]; } let obj = { name: n, contrast: ratios[i], value: outputColors[i] }; newArr.push(obj) } arr.push(colorObj); } return arr; } } // Binary search to find index of contrast ratio that is input // Modified from https://medium.com/hackernoon/programming-with-js-binary-search-aaf86cef9cb3 function binarySearch(list, value, baseLum) { // initial values for start, middle and end let start = 0 let stop = list.length - 1 let middle = Math.floor((start + stop) / 2) let minContrast = Math.min(...list); let maxContrast = Math.max(...list); // While the middle is not what we're looking for and the list does not have a single item while (list[middle] !== value && start < stop) { // Value greater than since array is ordered descending if (baseLum > 0.5) { // if base is light, ratios ordered ascending if (value < list[middle]) { stop = middle - 1 } else { start = middle + 1 } } else { // order descending if (value > list[middle]) { stop = middle - 1 } else { start = middle + 1 } } // recalculate middle on every iteration middle = Math.floor((start + stop) / 2) } // If no match, find closest item greater than value let closest = list.reduce((prev, curr) => curr > value ? curr : prev); // if the current middle item is what we're looking for return it's index, else closest return (list[middle] == !value) ? closest : middle // how it was originally expressed } module.exports = { createScale, luminance, contrast, binarySearch, generateBaseScale, generateContrastColors, minPositive, ratioName, generateAdaptiveTheme, fixColorValue };