diff --git a/package-lock.json b/package-lock.json index 75643dbb00d..530d13f8373 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10088,12 +10088,6 @@ "@types/node": "*" } }, - "node_modules/@types/chroma-js": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.4.4.tgz", - "integrity": "sha512-/DTccpHTaKomqussrn+ciEvfW4k6NAHzNzs/sts1TCqg333qNxOhy8TNIoQCmbGG3Tl8KdEhkGAssb1n3mTXiQ==", - "dev": true - }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -14122,11 +14116,6 @@ "node": ">=10" } }, - "node_modules/chroma-js": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz", - "integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==" - }, "node_modules/chrome-trace-event": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", @@ -14639,6 +14628,11 @@ "color-support": "bin.js" } }, + "node_modules/color2k": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/color2k/-/color2k-2.0.3.tgz", + "integrity": "sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog==" + }, "node_modules/colord": { "version": "2.9.3", "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", @@ -55552,14 +55546,13 @@ "license": "Apache-2.0", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "chroma-js": "^2.4.2", + "color2k": "^2.0.3", "lodash.get": "^4.4.2", "lodash.memoize": "^4.1.2", "polished": "^4.0.0", "prop-types": "^15.5.7" }, "devDependencies": { - "@types/chroma-js": "2.4.4", "@types/lodash.get": "4.4.9", "@types/lodash.memoize": "4.1.9" }, diff --git a/packages/theming/package.json b/packages/theming/package.json index bcc358f3a61..1600877c159 100644 --- a/packages/theming/package.json +++ b/packages/theming/package.json @@ -22,7 +22,7 @@ "types": "dist/typings/index.d.ts", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "chroma-js": "^2.4.2", + "color2k": "^2.0.3", "lodash.get": "^4.4.2", "lodash.memoize": "^4.1.2", "polished": "^4.0.0", @@ -34,7 +34,6 @@ "styled-components": "^4.2.0 || ^5.3.1" }, "devDependencies": { - "@types/chroma-js": "2.4.4", "@types/lodash.get": "4.4.9", "@types/lodash.memoize": "4.1.9" }, diff --git a/packages/theming/src/utils/getColor.spec.ts b/packages/theming/src/utils/getColor.spec.ts index 93dd36513d0..f52eed5335f 100644 --- a/packages/theming/src/utils/getColor.spec.ts +++ b/packages/theming/src/utils/getColor.spec.ts @@ -10,7 +10,7 @@ import DEFAULT_THEME from '../elements/theme'; import PALETTE from '../elements/palette'; import { IGardenTheme } from '../types'; import { darken, lighten, rgba } from 'polished'; -import { valid } from 'chroma-js'; +import { parseToRgba } from 'color2k'; const DARK_THEME: IGardenTheme = { ...DEFAULT_THEME, @@ -201,7 +201,7 @@ describe('getColor', () => { const theme = { ...DEFAULT_THEME, palette: { custom: '#fd5a1e' } }; const adjustedColor = getColor({ theme, hue: 'custom', shade: 600 }); - expect(valid(adjustedColor)).toBe(true); + expect(!!parseToRgba(adjustedColor)).toBe(true); theme.palette.custom = adjustedColor; diff --git a/packages/theming/src/utils/getColor.ts b/packages/theming/src/utils/getColor.ts index d1f2fdb9d24..c7da1a5e648 100644 --- a/packages/theming/src/utils/getColor.ts +++ b/packages/theming/src/utils/getColor.ts @@ -5,16 +5,13 @@ * found at http://www.apache.org/licenses/LICENSE-2.0. */ -import { scale, valid } from 'chroma-js'; -import { darken, lighten, rgba } from 'polished'; +import { getScale, parseToRgba } from 'color2k'; +import { darken, getContrast, lighten, rgba } from 'polished'; import get from 'lodash.get'; import memoize from 'lodash.memoize'; import DEFAULT_THEME from '../elements/theme'; -import PALETTE from '../elements/palette'; import { ColorParameters, Hue, IGardenTheme } from '../types'; -const PALETTE_SIZE = Object.keys(PALETTE.blue).length; - const adjust = (color: string, expected: number, actual: number) => { if (expected !== actual) { // Adjust darkness/lightness if color is not the expected shade @@ -67,6 +64,101 @@ const toHex = ( return retVal; }; +/* Validates color */ +const isValidColor = (maybeColor: any) => { + try { + return !!parseToRgba(maybeColor); + } catch { + return false; + } +}; + +/** + * + * Finds the index of the nearest element to a given target value in a sorted array using a binary search approach. + */ +function findNearestIndex(target: number, arr: number[], startIndex = 0) { + if (typeof target !== 'number' || isNaN(target)) { + throw new Error('Target must be a number.'); + } + if (!Array.isArray(arr)) { + throw new Error('Second argument must be an array.'); + } + + let left = startIndex; + let right = arr.length - 1; + + if (target < arr[left]) return left; + if (target > arr[right]) return right; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + if (arr[mid] === target) { + return mid; + } else if (arr[mid] < target) { + left = mid + 1; + } else { + right = mid - 1; + } + } + return arr[left] - target < target - arr[right] ? left : right; +} + +const OFFSET_TO_TARGET_RATIO = { + 100: 1.08, + 200: 1.2, + 300: 1.35, + 400: 2, + 500: 2.8, + 600: 3.3, + 700: 5, + 800: 10, + 900: 13, + 1000: 16, + 1100: 17.5, + 1200: 19 +}; + +/** + * Generates a 12-step offset-based color scale. + * Each key is an offset value and the corresponding value + * is the color that best matches the target contrast ratio for that offset. + */ +const generateColorScale = memoize((color: string) => { + /** + * Based on empirical research, a scale of 200 colors + * provided the best precision to size ratio. + */ + const scaleSize = 200; + const _scale = getScale('#FFF', color, '#000'); + const scale = (x: number) => _scale(x / scaleSize); + + const colors = []; + const contrastRatios = []; + + for (let i = 0; i <= scaleSize; i++) { + const _color = scale(i); + colors.push(_color); + contrastRatios.push(getContrast('#FFF', _color)); + } + + const palette: Record = {}; + let startIndex = 0; + + for (const offset in OFFSET_TO_TARGET_RATIO) { + if (Object.prototype.hasOwnProperty.call(OFFSET_TO_TARGET_RATIO, offset)) { + const ratio = (OFFSET_TO_TARGET_RATIO as any)[offset]; + + const nearestIndex = findNearestIndex(ratio, contrastRatios, startIndex); + startIndex = nearestIndex + 1; + + palette[offset] = colors[nearestIndex]; + } + } + + return palette; +}); + /* convert the given hue + shade to a color */ const toColor = ( colors: Omit, @@ -89,21 +181,11 @@ const toColor = ( if (typeof _hue === 'object') { retVal = toHex(_hue, shade, offset, scheme); - } else if (_hue === 'transparent' || valid(_hue)) { + } else if (_hue === 'transparent' || isValidColor(_hue)) { if (shade === undefined) { retVal = _hue; } else { - const _colors = scale([PALETTE.white, _hue, PALETTE.black]) - .correctLightness() - .colors(PALETTE_SIZE + 2); // add 2 to account for the white and black endpoints removed below - - _hue = _colors.reduce>((_retVal, color, index) => { - if (index > 0 && index <= PALETTE_SIZE) { - _retVal[index * 100] = color; - } - - return _retVal; - }, {}); + _hue = generateColorScale(_hue); retVal = toHex(_hue, shade, offset, scheme); }