UNPKG

@adobe/leonardo-contrast-colors

Version:

Generate colors based on a desired contrast ratio

697 lines (575 loc) 18.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createScale = createScale; exports.luminance = luminance; exports.contrast = contrast; exports.binarySearch = binarySearch; exports.generateBaseScale = generateBaseScale; exports.generateContrastColors = generateContrastColors; exports.minPositive = minPositive; exports.ratioName = ratioName; exports.generateAdaptiveTheme = generateAdaptiveTheme; var _d = _interopRequireDefault(require("d3")); var d3cam02 = _interopRequireWildcard(require("d3-cam02")); var _d3Hsluv = _interopRequireDefault(require("d3-hsluv")); var d3hsv = _interopRequireWildcard(require("d3-hsv")); var _curve = require("./curve.js"); function _getRequireWildcardCache() { if (typeof WeakMap !== "function") return null; var cache = new WeakMap(); _getRequireWildcardCache = function () { return cache; }; return cache; } function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } /* 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. */ // Work around node and babel's difference of opinion on the read-onlyness of default function assign(dest, ...src) { for (let obj of src) { for (let prop in obj) { if (prop !== 'default') { dest[prop] = obj[prop]; } } } } let interpolateJch = (start, end) => { // constant, linear, and colorInterpolate are taken from d3-interpolate // the colorInterpolate function is `nogamma` in the d3-interpolate's color.js const constant = x => () => x; const linear = (a, d) => t => a + t * d; const colorInterpolate = (a, b) => { const d = b - a; return d ? linear(a, d) : constant(isNaN(a) ? b : a); }; start = _d.default.jch(start); end = _d.default.jch(end); const zero = Math.abs(start.h - end.h); const plus = Math.abs(start.h - (end.h + 360)); const minus = Math.abs(start.h - (end.h - 360)); if (plus < zero && plus < minus) { end.h += 360; } if (minus < zero && minus < plus) { end.h -= 360; } const startc = _d.default.hcl(start + '').c; const endc = _d.default.hcl(end + '').c; if (!startc) { start.h = end.h; } if (!endc) { end.h = start.h; } const J = colorInterpolate(start.J, end.J), C = colorInterpolate(start.C, end.C), h = colorInterpolate(start.h, end.h), opacity = colorInterpolate(start.opacity, end.opacity); return t => { start.J = J(t); start.C = C(t); start.h = h(t); start.opacity = opacity(t); return start + ''; }; }; 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 = _d.default.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 => (0, _curve.catmullRom2bezier)(point).map(curve => (0, _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 _d.default[space.name](...ch) + ""; }; } // assign(d3, d3hsluv, d3hsv, d3cam02); const colorSpaces = { CAM02: { name: 'jab', channels: ['J', 'a', 'b'], interpolator: _d.default.interpolateJab }, CAM02p: { name: 'jch', channels: ['J', 'C', 'h'], interpolator: interpolateJch }, LCH: { name: 'hcl', channels: ['h', 'c', 'l'], interpolator: _d.default.interpolateHcl, white: _d.default.hcl(NaN, 0, 100), black: _d.default.hcl(NaN, 0, 0) }, LAB: { name: 'lab', channels: ['l', 'a', 'b'], interpolator: _d.default.interpolateLab }, HSL: { name: 'hsl', channels: ['h', 's', 'l'], interpolator: _d.default.interpolateHsl }, HSLuv: { name: 'hsluv', channels: ['l', 'u', 'v'], interpolator: _d.default.interpolateHsluv, white: _d3Hsluv.default.hsluv(NaN, NaN, 100), black: _d3Hsluv.default.hsluv(NaN, NaN, 0) }, RGB: { name: 'rgb', channels: ['r', 'g', 'b'], interpolator: _d.default.interpolateRgb }, HSV: { name: 'hsv', channels: ['h', 's', 'v'], interpolator: _d.default.interpolateHsv } }; function cArray(c) { const color = _d3Hsluv.default.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 * (_d3Hsluv.default.hsluv(key).v / 100)).sort((a, b) => a - b).concat(swatches); domains.unshift(0); // Test logarithmic domain (for non-contrast-based scales) let sqrtDomains = _d.default.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 => _d.default[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 = _d.default.hcl(stringColors[i]); if (!color.c) { ColorsArray[i].h = NaN; } } } if (smooth) { scale = smoothScale(ColorsArray, domains, space); } else { scale = _d.default.scaleLinear().range(ColorsArray).domain(domains).interpolate(space.interpolator); } let Colors = _d.default.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(_d.default.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 } = {}) { 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`); } let swatches = 3000; let scaleData = createScale({ swatches: swatches, colorKeys: colorKeys, colorspace: colorspace, shift: 1, smooth: smooth }); let baseV = _d3Hsluv.default.hsluv(base).v / 100; let Contrasts = _d.default.range(swatches).map(d => { let rgbArray = [_d.default.rgb(scaleData.scale(d)).r, _d.default.rgb(scaleData.scale(d)).g, _d.default.rgb(scaleData.scale(d)).b]; let baseRgbArray = [_d.default.rgb(base).r, _d.default.rgb(base).g, _d.default.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); newColors.push(_d.default.rgb(scaleData.colors[r]).hex()); } return newColors; } 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 }) { 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'); } if (brightness === undefined) { return function (brightness, contrast) { return generateAdaptiveTheme({ baseScale: baseScale, colorScales: colorScales, brightness: brightness, contrast: contrast }); }; } 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 ratios = colorScales[i].ratios; 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 }); for (let i = 0; i < outputColors.length; i++) { let rVal = ratioName(ratios)[i]; let n = name.concat(rVal); 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 }