Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .eslintrc
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"env": {
"node": true,
"browser": true
"es2021": true,
"node": true
},
"extends": ["eslint:recommended", "eslint-config-prettier"],
"rules": {
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ color.getColor();
color.getColor();
```

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

```js
import toColor from '@mapbox/to-color'

const color = new toColor('trees');

color.getColor('cedar'); // Returns a random color based on `trees`
color.getColor('birch');
color.getColor('cedar'); // Returns the same color for cedar
color.getColor('spruce');
```

### Options

| Option | Value | Default | Description |
Expand Down
43 changes: 43 additions & 0 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@ <h1 class="txt-h4 txt-fancy block mb6">@mapbox/to-color</h1>
<p class="txt-l">Procedurally generate a deterministic, perceptually distributed color palette. See <a href="https://github.com/mapbox/to-color" class="link" target="_blank">github.com/mapbox/to-color</a> for more.</p>
</header>
<div id="swatches" class="grid grid--gut12 txt-mono"></div>
<div class="mt36 mb24">
<p class="txt-l"><code class="txt-code">getColor</code> can optionally take a seed to guarantee the same random color regardless of its order.</p>
</div>
<div id="get-color-option-swatches" class="grid grid--gut12 txt-mono"></div>
</div>
<script>
const wrapOrNah = val => typeof val === 'string' ? `'${val}'` : val;
const swatches = document.getElementById('swatches');
const getColorOptionSwatches = document.getElementById('get-color-option-swatches');

[
['mouse', 6],
Expand Down Expand Up @@ -61,6 +66,44 @@ <h1 class="txt-h4 txt-fancy block mb6">@mapbox/to-color</h1>
container.appendChild(swatchSnippet);
swatches.appendChild(container);
});

[
['trees', ['cedar', 'birch', 'cedar', 'willow', 'birch', 'oak'], { brightness: 0 }],
['trees', ['birch', 'cedar', 'willow', 'birch', 'cedar', 'oak'], { brightness: 1.5 }],
['veneers', ['cedar', 'birch', 'cedar', 'willow', 'birch', 'oak'], { brightness: 0 }],
['veneers', ['cedar', 'birch', 'cedar', 'willow', 'birch', 'oak'], { brightness: 1.5 }]
].forEach(swatch => {
const seed = swatch[0];
const seededPalette = swatch[1];
const options = swatch[2];

const container = document.createElement('div');
const swatchSnippet = document.createElement('div');
swatchSnippet.classList = 'bg-gray-dark color-gray-light txt-mono px12 py12 round-b txt-truncate';
const swatchContainer = document.createElement('div');
swatchContainer.classList = 'round-t h120 txt-xs txt-bold flex-parent scroll-hidden';
const s = new toColor(seed, options || {});

container.classList = `col col--12 ${seededPalette.length < 8 ? 'col--6-ml' : ''} mb12`;

seededPalette.forEach(p => {
const { hsl } = s.getColor(p);
const swatchContainerItem = document.createElement('div');
swatchContainerItem.classList = `px12 py12 flex-child flex-child--grow h120`;
swatchContainerItem.style.flexBasis = 0;
swatchContainerItem.style.backgroundColor = hsl.formatted;
swatchContainerItem.textContent = swatchContainerItem.title = `getColor ('${p}')`;
swatchContainer.appendChild(swatchContainerItem);
});

let optionsString = options ? `, ${JSON.stringify(options)}` : '';
let textContent = `${wrapOrNah(seed)}`
swatchSnippet.textContent = swatchSnippet.title = `new toColor(${textContent}${optionsString});`;

container.appendChild(swatchContainer);
container.appendChild(swatchSnippet);
getColorOptionSwatches.appendChild(container);
});
</script>
</body>
</html>
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mapbox/to-color",
"version": "2.2.0",
"version": "2.3.0",
"description": "Procedurally generate a deterministic, perceptually distributed color palette.",
"main": "dist/to-color.js",
"scripts": {
Expand Down
50 changes: 40 additions & 10 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,43 +18,73 @@ export default class toColor {
constructor(seed, options) {
this.options = options || {};
if (typeof seed === 'string' || typeof seed === 'number') {
this.seed = typeof seed === 'string' ? this._stringToInteger(seed) : seed;
this.rootSeed =
typeof seed === 'string' ? this._stringToInteger(seed) : seed;
} else {
throw new TypeError('Seed value must be a number or string');
}

this.seed = this.rootSeed;
this.known = [];
this.cache = new Map();
}

getColor(count = 0) {
getColor(key) {
if (typeof key === 'string') {
if (this.cache.has(key)) return this.cache.get(key);

const color = this._getDeterministicColor(key);
this.cache.set(key, color);
return color;
}

return this._getSequentialColor();
}

_getDeterministicColor(key) {
const combined = this._stringToInteger(`${this.rootSeed}:${key}`);

const h = this._mapIndexToHue(combined);
const s = this._mapIndexToRange(combined >> 2, 60, 100);
const l = this._mapIndexToRange(combined >> 3, 35, 80);

return this._colorWithModifiers(h, s, l);
}

_getSequentialColor(count = 0) {
const h = this._pickHue();
const s = this._pickSaturation();
const l = this._pickLightness();

const { hsl } = this._HSLuvify(h, s, l);
const PASSABLE_DISTANCE = 60;

// The larger `count` grows, we need to divide actual distance to avoid
// hitting a maxiumum call stack error.
const ACTUAL_DISTANCE = PASSABLE_DISTANCE / Math.pow(1.05, count);

// Detect color similarity. If values are too close to one another, call
// getColor until enough dissimilarity is achieved.
if (
this.known.length &&
this.known.some(
(v) => differenceCiede2000(v, hsl.formatted) < ACTUAL_DISTANCE
)
) {
return this.getColor(count + 1);
return this._getSequentialColor(count + 1);
} else {
this.known.push(hsl.formatted);
// Apply modifiers after distribution check + regeneration to ensure
// colors with brightness/saturation adjustments remain the same.
return this._colorWithModifiers(h, s, l);
}
}

_mapIndexToHue(index) {
// A hybrid approach to color distance checking in _getSequentialColor but
// for `_getDeterministicColor`. Attempts to “spread” hash values evenly to
// reduce the same hues appearing next to one another.
const GOLDEN_RATIO_CONJUGATE = (Math.sqrt(5) - 1) / 2; // ≈ 0.61803398875
return Math.round(((index * GOLDEN_RATIO_CONJUGATE) % 1) * this.HUE_MAX);
}

_mapIndexToRange(index, min, max) {
return min + (index % (max - min));
}

_clamp = (n, min, max) => (n <= min ? min : n >= max ? max : n);

_colorWithModifiers = (h, s, l) => {
Expand Down
46 changes: 46 additions & 0 deletions src/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,52 @@ describe('toColor', () => {
});
});

describe('getColor seeding', () => {
it('returns the same color regardless of order', () => {
const color = new toColor('genres');

const a = color.getColor('jazz');
const b = color.getColor('fusion');
const c = color.getColor('jazz');

expect(a.hsl.formatted).toEqual(c.hsl.formatted);
expect(a.hsl.formatted).not.toEqual(b.hsl.formatted);
});

it('works with root seeding being different', () => {
const colorA = new toColor('genres');
const colorB = new toColor('dance');

const aa = colorA.getColor('jazz');
const ab = colorA.getColor('fusion');
const ac = colorA.getColor('jazz');

const ba = colorB.getColor('jazz');
const bb = colorB.getColor('fusion');
const bc = colorB.getColor('jazz');

expect(aa.hsl.formatted).toEqual(ac.hsl.formatted);
expect(aa.hsl.formatted).not.toEqual(ab.hsl.formatted);

expect(aa.hsl.formatted).not.toEqual(ba.hsl.formatted);
expect(ba.hsl.formatted).not.toEqual(bb.hsl.formatted);
expect(ba.hsl.formatted).toEqual(bc.hsl.formatted);
});

it('determinisic regardless of order', () => {
const colorA = new toColor('dance');
const colorB = new toColor('dance');

const aa = colorA.getColor('jazz'); // Defined first
colorA.getColor('fusion');

colorB.getColor('fusion');
const bb = colorB.getColor('jazz'); // Defined last

expect(aa.hsl.formatted).toEqual(bb.hsl.formatted);
});
});

describe('distribution drops as recursion of getColor increases', () => {
const color = new toColor('tristen');

Expand Down