Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 = 0.61803398875;
return Math.round(((index * golden) % 1) * this.HUE_MAX);
}
Comment on lines +76 to +82
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI proposed this idea as a cheap way to spread hues more distributed across the palette as fetching a deterministic value and comparing it using color difference would require knowing the whole list of values ahead of time.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's actually not a bad approach, and picked an irrational number too, so it never repeats, which is cool. I'd maybe use a constant like Math.E though, so that we don't have magic numbers in the code that have low precision or need an explanation


_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');

const bb = colorB.getColor('fusion');
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
Loading