├── .nvmrc ├── _config.yml ├── .husky └── pre-commit ├── .publisher.yml ├── .artifacts.yml ├── .gitignore ├── src ├── d3-color-difference │ ├── index.js │ ├── differenceCiede2000.test.js │ └── differenceCiede2000.js ├── index.test.js └── index.js ├── babel.config.js ├── jest.config.js ├── .eslintrc ├── rollup.config.js ├── LICENSE ├── .github └── workflows │ └── basic.yml ├── README.md ├── package.json └── demo └── index.html /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | source: demo 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.publisher.yml: -------------------------------------------------------------------------------- 1 | subdomain: labs 2 | -------------------------------------------------------------------------------- /.artifacts.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | defaults: 3 | - publisher 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | _site/ 3 | dist/ 4 | demo/to-color.js 5 | demo/to-color.js.map 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /src/d3-color-difference/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | default as differenceCiede2000, 3 | differenceCiede2000Weighted 4 | } from './differenceCiede2000'; 5 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | env: { 5 | test: { 6 | presets: [['@babel/preset-env', { targets: { node: 'current' } }]] 7 | } 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = { 4 | transform: { 5 | '\\.[jt]s?$': 'babel-jest' 6 | }, 7 | testEnvironment: 'node', 8 | transformIgnorePatterns: ['/node_modules/(?!d3-color)'], 9 | clearMocks: true 10 | }; 11 | 12 | module.exports = config; 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2021": true, 4 | "node": true 5 | }, 6 | "extends": ["eslint:recommended", "eslint-config-prettier"], 7 | "rules": { 8 | "no-constant-condition": 0, 9 | "indent": ["error", 2], 10 | "no-console": "warn" 11 | }, 12 | "parser": "babel-eslint", 13 | "overrides": [ 14 | { 15 | "files": ["*.test.js"], 16 | "env": { 17 | "jest": true 18 | } 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | 5 | export default { 6 | input: ['src/index.js'], 7 | output: [ 8 | { 9 | name: 'toColor', 10 | file: 'dist/to-color.js', 11 | format: 'umd' 12 | }, { 13 | name: 'toColor', 14 | file: 'demo/to-color.js', 15 | format: 'umd', 16 | sourcemap: true 17 | } 18 | ], 19 | treeshake: true, 20 | plugins: [ 21 | babel(), 22 | resolve(), 23 | commonjs() 24 | ] 25 | }; 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | ISC License 3 | 4 | Copyright (c) 2017, Mapbox 5 | 6 | Permission to use, copy, modify, and/or distribute this software for any 7 | purpose with or without fee is hereby granted, provided that the above 8 | copyright notice and this permission notice appear in all copies. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | -------------------------------------------------------------------------------- /.github/workflows/basic.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | prepare: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | id-token: write 11 | contents: read 12 | outputs: 13 | isCodeChange: ${{ steps.changed-files.outputs.any_changed }} 14 | isDeployment: ${{ github.base_ref == 'refs/heads/staging' }} 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version-file: .nvmrc 20 | cache: npm 21 | 22 | - name: Install npm dependencies 23 | run: npm ci --no-fund --no-audit --prefer-offline 24 | 25 | lint: 26 | runs-on: ubuntu-latest 27 | needs: prepare 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: actions/setup-node@v4 31 | with: 32 | node-version-file: .nvmrc 33 | cache: npm 34 | cache-dependency-path: '**/package-lock.json' 35 | - name: Install npm dependencies 36 | run: npm install --no-fund --no-audit --prefer-offline 37 | 38 | - run: npm run test 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `@mapbox/to-color` 2 | === 3 | 4 | Procedurally generate a deterministic, perceptually distributed color palette. Uses [HSLuv](https://www.hsluv.org/) internally for a uniform saturation between palattes. 5 | 6 | ### install 7 | 8 | ```bash 9 | npm install @mapbox/to-color 10 | ``` 11 | 12 | ### Usage 13 | 14 | ```js 15 | import toColor from '@mapbox/to-color' 16 | 17 | const color = new toColor('tmcw'); 18 | 19 | // Or a number 20 | // const color = new toColor(1234); 21 | // Or with options 22 | // const color = new toColor('tmcw', { brightness: 0.25, saturation: 1.1 }); 23 | 24 | color.getColor(); 25 | 26 | // Multiple calls return a new deterministic random color 27 | color.getColor(); 28 | ``` 29 | 30 | `getColor` can optionally take a seed to guarantee the same random color regardless of order. 31 | 32 | ```js 33 | import toColor from '@mapbox/to-color' 34 | 35 | const color = new toColor('trees'); 36 | 37 | color.getColor('cedar'); // Returns a random color based on `trees` 38 | color.getColor('birch'); 39 | color.getColor('cedar'); // Returns the same color for cedar 40 | color.getColor('spruce'); 41 | ``` 42 | 43 | ### Options 44 | 45 | | Option | Value | Default | Description | 46 | | --- | --- | --- | --- | 47 | | `brightness` | `Number` | 0 | Adjusts brightness percentage from the derived min/max range. | 48 | | `saturation` | `Number` | 0 | Adjusts saturation percentage from the derived min/max range. | 49 | | `limit` | `Array` | `[]` | Limits the higher range of hues for a given color. Options can be `red`, `orange`, `yellow`, `green`, `blue`, `purple`, or `pink`. | 50 | 51 | ### Developing 52 | 53 | ```bash 54 | # Demo site 55 | npm install & npm start 56 | 57 | # Run tests 58 | npm run test 59 | ``` 60 | 61 | --- 62 | 63 | **Credit** v2 is adapted from [randomColor](https://github.com/davidmerfield/randomColor). 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mapbox/to-color", 3 | "version": "2.3.0", 4 | "description": "Procedurally generate a deterministic, perceptually distributed color palette.", 5 | "main": "dist/to-color.js", 6 | "scripts": { 7 | "start:server": "serve demo", 8 | "start": "run-p start:server watch", 9 | "watch": "watch 'npm run compile' src", 10 | "compile": "rollup -c", 11 | "build": "npm run compile && rm -rf _site && mkdir -p _site && cp -R demo/ _site/", 12 | "test": "eslint src && jest", 13 | "jest-watch": "jest --watch", 14 | "prepare": "husky" 15 | }, 16 | "keywords": [ 17 | "color", 18 | "string", 19 | "seed", 20 | "identifier" 21 | ], 22 | "author": "Mapbox", 23 | "license": "ISC", 24 | "devDependencies": { 25 | "@babel/core": "^7.28.0", 26 | "@babel/plugin-proposal-class-properties": "^7.12.1", 27 | "@babel/preset-env": "^7.12.11", 28 | "@rollup/plugin-commonjs": "^17.1.0", 29 | "@rollup/plugin-node-resolve": "^11.1.1", 30 | "babel-eslint": "^10.1.0", 31 | "babel-jest": "^26.6.3", 32 | "eslint": "^7.18.0", 33 | "eslint-config-prettier": "^7.2.0", 34 | "husky": "^9.1.7", 35 | "jest": "^30.0.5", 36 | "lint-staged": "^16.1.5", 37 | "npm-run-all": "^4.1.5", 38 | "prettier": "^3.6.2", 39 | "rollup": "^2.38.1", 40 | "rollup-plugin-babel": "^4.4.0", 41 | "serve": "^14.2.4", 42 | "watch": "^1.0.2" 43 | }, 44 | "babel": { 45 | "presets": [ 46 | "@babel/preset-env" 47 | ], 48 | "plugins": [ 49 | "@babel/plugin-proposal-class-properties" 50 | ] 51 | }, 52 | "lint-staged": { 53 | "**/*.js": [ 54 | "prettier --write", 55 | "eslint --fix" 56 | ] 57 | }, 58 | "prettier": { 59 | "trailingComma": "none", 60 | "singleQuote": true 61 | }, 62 | "husky": { 63 | "hooks": { 64 | "pre-commit": "lint-staged" 65 | } 66 | }, 67 | "repository": { 68 | "type": "git", 69 | "url": "git@github.com:mapbox/to-color.git" 70 | }, 71 | "dependencies": { 72 | "d3-color": "^3.1.0", 73 | "hsluv": "^1.0.1" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/d3-color-difference/differenceCiede2000.test.js: -------------------------------------------------------------------------------- 1 | import { differenceCiede2000 } from '.'; 2 | import { lab } from 'd3-color'; 3 | 4 | // Test data from: http://www2.ece.rochester.edu/~gsharma/ciede2000/ 5 | let testdata = `50.0000 2.6772 -79.7751 50.0000 0.0000 -82.7485 2.0425 6 | 50.0000 3.1571 -77.2803 50.0000 0.0000 -82.7485 2.8615 7 | 50.0000 2.8361 -74.0200 50.0000 0.0000 -82.7485 3.4412 8 | 50.0000 -1.3802 -84.2814 50.0000 0.0000 -82.7485 1.0000 9 | 50.0000 -1.1848 -84.8006 50.0000 0.0000 -82.7485 1.0000 10 | 50.0000 -0.9009 -85.5211 50.0000 0.0000 -82.7485 1.0000 11 | 50.0000 0.0000 0.0000 50.0000 -1.0000 2.0000 2.3669 12 | 50.0000 -1.0000 2.0000 50.0000 0.0000 0.0000 2.3669 13 | 50.0000 2.4900 -0.0010 50.0000 -2.4900 0.0009 7.1792 14 | 50.0000 2.4900 -0.0010 50.0000 -2.4900 0.0010 7.1792 15 | 50.0000 2.4900 -0.0010 50.0000 -2.4900 0.0011 7.2195 16 | 50.0000 2.4900 -0.0010 50.0000 -2.4900 0.0012 7.2195 17 | 50.0000 -0.0010 2.4900 50.0000 0.0009 -2.4900 4.8045 18 | 50.0000 -0.0010 2.4900 50.0000 0.0010 -2.4900 4.8045 19 | 50.0000 -0.0010 2.4900 50.0000 0.0011 -2.4900 4.7461 20 | 50.0000 2.5000 0.0000 50.0000 0.0000 -2.5000 4.3065 21 | 50.0000 2.5000 0.0000 73.0000 25.0000 -18.0000 27.1492 22 | 50.0000 2.5000 0.0000 61.0000 -5.0000 29.0000 22.8977 23 | 50.0000 2.5000 0.0000 56.0000 -27.0000 -3.0000 31.9030 24 | 50.0000 2.5000 0.0000 58.0000 24.0000 15.0000 19.4535 25 | 50.0000 2.5000 0.0000 50.0000 3.1736 0.5854 1.0000 26 | 50.0000 2.5000 0.0000 50.0000 3.2972 0.0000 1.0000 27 | 50.0000 2.5000 0.0000 50.0000 1.8634 0.5757 1.0000 28 | 50.0000 2.5000 0.0000 50.0000 3.2592 0.3350 1.0000 29 | 60.2574 -34.0099 36.2677 60.4626 -34.1751 39.4387 1.2644 30 | 63.0109 -31.0961 -5.8663 62.8187 -29.7946 -4.0864 1.2630 31 | 61.2901 3.7196 -5.3901 61.4292 2.2480 -4.9620 1.8731 32 | 35.0831 -44.1164 3.7933 35.0232 -40.0716 1.5901 1.8645 33 | 22.7233 20.0904 -46.6940 23.0331 14.9730 -42.5619 2.0373 34 | 36.4612 47.8580 18.3852 36.2715 50.5065 21.2231 1.4146 35 | 90.8027 -2.0831 1.4410 91.1528 -1.6435 0.0447 1.4441 36 | 90.9257 -0.5406 -0.9208 88.6381 -0.8985 -0.7239 1.5381 37 | 6.7747 -0.2908 -2.4247 5.8714 -0.0985 -2.2286 0.6377 38 | 2.0776 0.0795 -1.1350 0.9033 -0.0636 -0.5514 0.9082` 39 | .split('\n') 40 | .map((line) => line.trim().split(/\s+/)); 41 | 42 | function round(value, precision) { 43 | return Math.round(value * (precision = Math.pow(10, precision))) / precision; 44 | } 45 | 46 | describe('differenceCiede2000', () => { 47 | it('Computes correctly the Sharma test data', () => { 48 | for (var i = 0; i < testdata.length; i++) { 49 | let line = testdata[i]; 50 | 51 | expect( 52 | round( 53 | differenceCiede2000( 54 | lab(line[0], line[1], line[2]), 55 | lab(line[3], line[4], line[5]) 56 | ), 57 | 4 58 | ) 59 | ).toBe(+line[6]); 60 | 61 | expect( 62 | round( 63 | differenceCiede2000( 64 | lab(line[3], line[4], line[5]), 65 | lab(line[0], line[1], line[2]) 66 | ), 67 | 4 68 | ) 69 | ).toBe(+line[6]); 70 | } 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/d3-color-difference/differenceCiede2000.js: -------------------------------------------------------------------------------- 1 | import { lab } from 'd3-color'; 2 | 3 | /* 4 | CIEDE2000 color difference, original Matlab implementation by Gaurav Sharma 5 | Based on "The CIEDE2000 Color-Difference Formula: Implementation Notes, Supplementary Test Data, and Mathematical Observations" 6 | by Gaurav Sharma, Wencheng Wu, Edul N. Dalal in Color Research and Application, vol. 30. No. 1, pp. 21-30, February 2005. 7 | http://www2.ece.rochester.edu/~gsharma/ciede2000/ 8 | */ 9 | 10 | function differenceCiede2000(kL, kC, kH) { 11 | kL = kL !== undefined ? kL : 1; 12 | kC = kC !== undefined ? kC : 1; 13 | kH = kH !== undefined ? kH : 1; 14 | 15 | return function (std, smp) { 16 | var LabStd = lab(std); 17 | var LabSmp = lab(smp); 18 | 19 | var lStd = LabStd.l; 20 | var aStd = LabStd.a; 21 | var bStd = LabStd.b; 22 | var cStd = Math.sqrt(aStd * aStd + bStd * bStd); 23 | 24 | var lSmp = LabSmp.l; 25 | var aSmp = LabSmp.a; 26 | var bSmp = LabSmp.b; 27 | var cSmp = Math.sqrt(aSmp * aSmp + bSmp * bSmp); 28 | 29 | var cAvg = (cStd + cSmp) / 2; 30 | 31 | var G = 32 | 0.5 * 33 | (1 - 34 | Math.sqrt(Math.pow(cAvg, 7) / (Math.pow(cAvg, 7) + Math.pow(25, 7)))); 35 | 36 | var apStd = aStd * (1 + G); 37 | var apSmp = aSmp * (1 + G); 38 | 39 | var cpStd = Math.sqrt(apStd * apStd + bStd * bStd); 40 | var cpSmp = Math.sqrt(apSmp * apSmp + bSmp * bSmp); 41 | 42 | var hpStd = 43 | Math.abs(apStd) + Math.abs(bStd) === 0 ? 0 : Math.atan2(bStd, apStd); 44 | hpStd += (hpStd < 0) * 2 * Math.PI; 45 | 46 | var hpSmp = 47 | Math.abs(apSmp) + Math.abs(bSmp) === 0 ? 0 : Math.atan2(bSmp, apSmp); 48 | hpSmp += (hpSmp < 0) * 2 * Math.PI; 49 | 50 | var dL = lSmp - lStd; 51 | var dC = cpSmp - cpStd; 52 | 53 | var dhp = cpStd * cpSmp === 0 ? 0 : hpSmp - hpStd; 54 | dhp -= (dhp > Math.PI) * 2 * Math.PI; 55 | dhp += (dhp < -Math.PI) * 2 * Math.PI; 56 | 57 | var dH = 2 * Math.sqrt(cpStd * cpSmp) * Math.sin(dhp / 2); 58 | 59 | var Lp = (lStd + lSmp) / 2; 60 | var Cp = (cpStd + cpSmp) / 2; 61 | 62 | var hp; 63 | if (cpStd * cpSmp === 0) { 64 | hp = hpStd + hpSmp; 65 | } else { 66 | hp = (hpStd + hpSmp) / 2; 67 | hp -= (Math.abs(hpStd - hpSmp) > Math.PI) * Math.PI; 68 | hp += (hp < 0) * 2 * Math.PI; 69 | } 70 | 71 | var Lpm50 = Math.pow(Lp - 50, 2); 72 | var T = 73 | 1 - 74 | 0.17 * Math.cos(hp - Math.PI / 6) + 75 | 0.24 * Math.cos(2 * hp) + 76 | 0.32 * Math.cos(3 * hp + Math.PI / 30) - 77 | 0.2 * Math.cos(4 * hp - (63 * Math.PI) / 180); 78 | 79 | var Sl = 1 + (0.015 * Lpm50) / Math.sqrt(20 + Lpm50); 80 | var Sc = 1 + 0.045 * Cp; 81 | var Sh = 1 + 0.015 * Cp * T; 82 | 83 | var deltaTheta = 84 | ((30 * Math.PI) / 180) * 85 | Math.exp(-1 * Math.pow(((180 / Math.PI) * hp - 275) / 25, 2)); 86 | var Rc = 87 | 2 * Math.sqrt(Math.pow(Cp, 7) / (Math.pow(Cp, 7) + Math.pow(25, 7))); 88 | 89 | var Rt = -1 * Math.sin(2 * deltaTheta) * Rc; 90 | 91 | return Math.sqrt( 92 | Math.pow(dL / (kL * Sl), 2) + 93 | Math.pow(dC / (kC * Sc), 2) + 94 | Math.pow(dH / (kH * Sh), 2) + 95 | (((Rt * dC) / (kC * Sc)) * dH) / (kH * Sh) 96 | ); 97 | }; 98 | } 99 | 100 | var differenceCiede2000Default = differenceCiede2000(); 101 | 102 | export { 103 | differenceCiede2000Default as default, 104 | differenceCiede2000 as differenceCiede2000Weighted 105 | }; 106 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import toColor from './index'; 2 | 3 | describe('toColor', () => { 4 | describe('throws', () => { 5 | it('invalid seed value passed', () => { 6 | try { 7 | new toColor([]); 8 | } catch (err) { 9 | expect(err).toEqual( 10 | new TypeError('Seed value must be a number or string') 11 | ); 12 | } 13 | }); 14 | }); 15 | 16 | describe('basic', () => { 17 | const color = new toColor('tristen'); 18 | 19 | it('returns deterministic color on getColor', () => { 20 | expect(color.getColor()).toEqual({ 21 | hsl: { 22 | formatted: 'hsl(47, 57%, 52%)', 23 | raw: [47, 57, 52] 24 | } 25 | }); 26 | }); 27 | 28 | it('returns a different determinisic value calling getColor again', () => { 29 | expect(color.getColor()).toEqual({ 30 | hsl: { 31 | formatted: 'hsl(315, 62%, 38%)', 32 | raw: [315, 62, 38] 33 | } 34 | }); 35 | }); 36 | }); 37 | 38 | describe('number as value', () => { 39 | const color = new toColor(1234); 40 | it('works with a number', () => { 41 | expect(color.getColor()).toEqual({ 42 | hsl: { 43 | formatted: 'hsl(152, 35%, 29%)', 44 | raw: [152, 35, 29] 45 | } 46 | }); 47 | }); 48 | }); 49 | 50 | describe('brightness/saturation', () => { 51 | const color = new toColor('penny', { brightness: 0.5, saturation: 0.5 }); 52 | 53 | it('returns deterministic color on getColor', () => { 54 | expect(color.getColor()).toEqual({ 55 | hsl: { 56 | formatted: 'hsl(87, 14%, 31%)', 57 | raw: [87, 14, 31] 58 | } 59 | }); 60 | }); 61 | 62 | it('returns a different determinisic value calling getColor again', () => { 63 | expect(color.getColor()).toEqual({ 64 | hsl: { 65 | formatted: 'hsl(337, 32%, 24%)', 66 | raw: [337, 32, 24] 67 | } 68 | }); 69 | }); 70 | }); 71 | 72 | describe('limit', () => { 73 | it('returns a blue color', () => { 74 | const color = new toColor('hi'); 75 | expect(color.getColor()).toEqual({ 76 | hsl: { 77 | formatted: 'hsl(181, 58%, 44%)', 78 | raw: [181, 58, 44] 79 | } 80 | }); 81 | }); 82 | 83 | it('returns a different color as blue is limited', () => { 84 | const color = new toColor('hi', { limit: ['blue'] }); 85 | expect(color.getColor()).toEqual({ 86 | hsl: { 87 | formatted: 'hsl(285, 62%, 52%)', 88 | raw: [285, 62, 52] 89 | } 90 | }); 91 | }); 92 | }); 93 | 94 | describe('getColor seeding', () => { 95 | it('returns the same color regardless of order', () => { 96 | const color = new toColor('genres'); 97 | 98 | const a = color.getColor('jazz'); 99 | const b = color.getColor('fusion'); 100 | const c = color.getColor('jazz'); 101 | 102 | expect(a.hsl.formatted).toEqual(c.hsl.formatted); 103 | expect(a.hsl.formatted).not.toEqual(b.hsl.formatted); 104 | }); 105 | 106 | it('works with root seeding being different', () => { 107 | const colorA = new toColor('genres'); 108 | const colorB = new toColor('dance'); 109 | 110 | const aa = colorA.getColor('jazz'); 111 | const ab = colorA.getColor('fusion'); 112 | const ac = colorA.getColor('jazz'); 113 | 114 | const ba = colorB.getColor('jazz'); 115 | const bb = colorB.getColor('fusion'); 116 | const bc = colorB.getColor('jazz'); 117 | 118 | expect(aa.hsl.formatted).toEqual(ac.hsl.formatted); 119 | expect(aa.hsl.formatted).not.toEqual(ab.hsl.formatted); 120 | 121 | expect(aa.hsl.formatted).not.toEqual(ba.hsl.formatted); 122 | expect(ba.hsl.formatted).not.toEqual(bb.hsl.formatted); 123 | expect(ba.hsl.formatted).toEqual(bc.hsl.formatted); 124 | }); 125 | 126 | it('determinisic regardless of order', () => { 127 | const colorA = new toColor('dance'); 128 | const colorB = new toColor('dance'); 129 | 130 | const aa = colorA.getColor('jazz'); // Defined first 131 | colorA.getColor('fusion'); 132 | 133 | colorB.getColor('fusion'); 134 | const bb = colorB.getColor('jazz'); // Defined last 135 | 136 | expect(aa.hsl.formatted).toEqual(bb.hsl.formatted); 137 | }); 138 | }); 139 | 140 | describe('distribution drops as recursion of getColor increases', () => { 141 | const color = new toColor('tristen'); 142 | 143 | it('calls getColor 1000 times', () => { 144 | let finalValue; 145 | for (let i = 0; i !== 1000; i++) { 146 | finalValue = color.getColor(); 147 | } 148 | expect(finalValue).toEqual({ 149 | hsl: { 150 | formatted: 'hsl(91, 84%, 33%)', 151 | raw: [91, 84, 33] 152 | } 153 | }); 154 | }); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |

@mapbox/to-color

14 |

Procedurally generate a deterministic, perceptually distributed color palette. See github.com/mapbox/to-color for more.

15 |
16 |
17 |
18 |

getColor can optionally take a seed to guarantee the same random color regardless of its order.

19 |
20 |
21 |
22 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { differenceCiede2000 } from './d3-color-difference'; 2 | import { color, hsl } from 'd3-color'; 3 | import { Hsluv } from 'hsluv'; 4 | 5 | export default class toColor { 6 | HUE_MAX = 360; 7 | 8 | hues = { 9 | red: [-26, 18], 10 | orange: [18, 46], 11 | yellow: [46, 62], 12 | green: [62, 178], 13 | blue: [178, 257], 14 | purple: [257, 282], 15 | pink: [282, 334] 16 | }; 17 | 18 | constructor(seed, options) { 19 | this.options = options || {}; 20 | if (typeof seed === 'string' || typeof seed === 'number') { 21 | this.rootSeed = 22 | typeof seed === 'string' ? this._stringToInteger(seed) : seed; 23 | } else { 24 | throw new TypeError('Seed value must be a number or string'); 25 | } 26 | 27 | this.seed = this.rootSeed; 28 | this.known = []; 29 | this.cache = new Map(); 30 | } 31 | 32 | getColor(key) { 33 | if (typeof key === 'string') { 34 | if (this.cache.has(key)) return this.cache.get(key); 35 | 36 | const color = this._getDeterministicColor(key); 37 | this.cache.set(key, color); 38 | return color; 39 | } 40 | 41 | return this._getSequentialColor(); 42 | } 43 | 44 | _getDeterministicColor(key) { 45 | const combined = this._stringToInteger(`${this.rootSeed}:${key}`); 46 | 47 | const h = this._mapIndexToHue(combined); 48 | const s = this._mapIndexToRange(combined >> 2, 60, 100); 49 | const l = this._mapIndexToRange(combined >> 3, 35, 80); 50 | 51 | return this._colorWithModifiers(h, s, l); 52 | } 53 | 54 | _getSequentialColor(count = 0) { 55 | const h = this._pickHue(); 56 | const s = this._pickSaturation(); 57 | const l = this._pickLightness(); 58 | 59 | const { hsl } = this._HSLuvify(h, s, l); 60 | const PASSABLE_DISTANCE = 60; 61 | const ACTUAL_DISTANCE = PASSABLE_DISTANCE / Math.pow(1.05, count); 62 | 63 | if ( 64 | this.known.length && 65 | this.known.some( 66 | (v) => differenceCiede2000(v, hsl.formatted) < ACTUAL_DISTANCE 67 | ) 68 | ) { 69 | return this._getSequentialColor(count + 1); 70 | } else { 71 | this.known.push(hsl.formatted); 72 | return this._colorWithModifiers(h, s, l); 73 | } 74 | } 75 | 76 | _mapIndexToHue(index) { 77 | // A hybrid approach to color distance checking in _getSequentialColor but 78 | // for `_getDeterministicColor`. Attempts to “spread” hash values evenly to 79 | // reduce the same hues appearing next to one another. 80 | const GOLDEN_RATIO_CONJUGATE = (Math.sqrt(5) - 1) / 2; // ≈ 0.61803398875 81 | return Math.round(((index * GOLDEN_RATIO_CONJUGATE) % 1) * this.HUE_MAX); 82 | } 83 | 84 | _mapIndexToRange(index, min, max) { 85 | return min + (index % (max - min)); 86 | } 87 | 88 | _clamp = (n, min, max) => (n <= min ? min : n >= max ? max : n); 89 | 90 | _colorWithModifiers = (h, s, l) => { 91 | const percentage = (n, per) => (n / 100) * per * 100; 92 | const { brightness, saturation } = this.options; 93 | 94 | // Modify brightness/saturation if provided 95 | s = saturation ? this._clamp(percentage(saturation, s), 0, 100) : s; 96 | l = brightness ? this._clamp(percentage(brightness, l), 0, 100) : l; 97 | 98 | return this._HSLuvify(h, s, l); 99 | }; 100 | 101 | _HSLuvify = (h, s, l) => { 102 | const conv = new Hsluv(); 103 | conv.hsluv_h = h; 104 | conv.hsluv_s = s; 105 | conv.hsluv_l = l; 106 | conv.hsluvToHex(); 107 | 108 | const c = color(conv.hex); 109 | const raw = hsl(c); 110 | 111 | // Convert and round 112 | const hRounded = Math.round(this._clamp(raw.h, 0, 360)); 113 | const sRounded = Math.round(this._clamp(raw.s * 100, 0, 100)); 114 | const lRounded = Math.round(this._clamp(raw.l * 100, 0, 100)); 115 | 116 | return { 117 | hsl: { 118 | raw: [hRounded, sRounded, lRounded], 119 | formatted: `hsl(${hRounded}, ${sRounded}%, ${lRounded}%)` 120 | } 121 | }; 122 | }; 123 | 124 | _pickHue = () => { 125 | let hue = this._pseudoRandom([0, this.HUE_MAX]); 126 | const min = hue % this.HUE_MAX; 127 | const max = (hue + 1) % this.HUE_MAX; 128 | 129 | hue = this._pseudoRandom([min, max]); 130 | 131 | // Red is on both ends of the color spectrum. Instead of storing red as two 132 | // ranges, lookup is grouped in `this.hue` as negative numbers. 133 | if (hue < 0) hue = this.HUE_MAX + hue; 134 | 135 | // Limit the max of some hues if the option is passed. 136 | const { limit } = this.options; 137 | 138 | if (limit && limit.length) { 139 | for (let i = 0; i !== limit.length; i++) { 140 | const hueRange = this.hues?.[limit[i]]; 141 | if (hueRange && hue > hueRange[0] && hue <= hueRange[1]) { 142 | return this._pickHue(); 143 | } 144 | } 145 | } 146 | 147 | return hue; 148 | }; 149 | 150 | _pickSaturation = () => { 151 | // HSLuv saturation can be high without RGB clipping, so keep near upper range 152 | return this._pseudoRandom([60, 100]); 153 | }; 154 | 155 | _pickLightness = () => { 156 | // Avoid extremes for better contrast 157 | return this._pseudoRandom([35, 80]); 158 | }; 159 | 160 | // A linear congruential generator (LCG) algorithm that yields a sequence of 161 | // pseudo-randomized numbers calculated with a discontinuous piecewise linear 162 | // equation. see: indiegamr.com/generate-repeatable-random-numbers-in-js 163 | _pseudoRandom = (range) => { 164 | const max = range[1] || 1; 165 | const min = range[0] || 0; 166 | this.seed = (this.seed * 9301 + 49297) % 233280; 167 | const rnd = this.seed / 233280; 168 | return Math.trunc(min + rnd * (max - min)); 169 | }; 170 | 171 | _stringToInteger = (string) => { 172 | let total = 0; 173 | for (let i = 0; i !== string.length; i++) { 174 | if (total >= Number.MAX_SAFE_INTEGER) break; 175 | total += string.charCodeAt(i); 176 | } 177 | return total; 178 | }; 179 | } 180 | --------------------------------------------------------------------------------