Generate color palettes for categorical data visualization using simulated annealing.
Categorical palettes have to satisfy several goals at once: every pair of colors must be tellable apart, they must stay tellable apart for viewers with color vision deficiency, they may need to hit a contrast ratio against the chart background, and they should look like they belong together. These goals conflict, so there is no palette that maximizes all of them. This library treats palette design as an optimization problem instead: you weight the goals you care about, and simulated annealing searches for the least-wrong compromise.
This is the code behind the essay How to pick the least wrong colors.
The essay's color-blindness images were made with Brettel, Viénot, and Mollon's 1997 simulation; the code has since moved to Machado, Oliveira, and Fernandes's 2009 model, via culori (see Credits).
Every evaluator measures something computable: perceptual distance, contrast, simulated color vision deficiency, name overlap. None of them measures whether people like a palette or whether its colors suit what they label, so the result is a starting point to test with the people who will read it, not a finished design.
npm install category-colorsRequires Node.js 22.12 or newer. The package is ESM; on Node 22.12+ require()
loads it as well, so CommonJS callers work without a build step.
import {
createDefaultConfig,
createDefaultState,
prepareInitialState,
runWithOrderOptimization,
} from 'category-colors';
const config = createDefaultConfig();
const initialState = prepareInitialState(createDefaultState(), config);
const finalState = runWithOrderOptimization(initialState, config);
console.log(finalState.colors.map(String));
// [ '#eedfda', '#b19aca', '#5b9590', ... ]prepareInitialState fills the palette out to config.colorCount, clamps every
color into the working space, and picks a starting temperature by sampling
random mutations. runWithOrderOptimization anneals and then reorders the
result so neighboring swatches sit at even perceptual distances; use
runSimulatedAnnealing to skip the reordering.
npx category-colors run
npx category-colors report '#ff0000' '#f10000' '#00ff00' --threshold 20Run options:
| Flag | Meaning |
|---|---|
-c, --config <path> |
Module exporting config overrides or a factory |
-s, --state <path> |
Module exporting a colors array or state overrides |
-f, --format <type> |
text (default), json, or palette |
-o, --output <path> |
Write to a file instead of stdout |
--no-order |
Skip post-annealing order optimization |
--quiet |
Suppress progress logging |
Report options audit an existing palette for pairs that fall below a just-noticeable-difference threshold:
| Flag | Meaning |
|---|---|
[colors...] |
Hex colors to audit when --palette is not used |
-p, --palette <path> |
Module exporting a colors array or state object |
-m, --method <name> |
Distance method (default ciede2000) |
--space <name> |
Distance space (default lab65) |
-t, --threshold <num> |
Flag pairs below this ΔE (default 25) |
--cvd <type:severity> |
Add a CVD simulation, e.g. deuteranomaly:0.5. Repeatable |
--palette-space <space> |
Report colors in this space instead of hex |
-f, --format <type> |
text (default) or json |
-o, --output <path> |
Write to a file instead of stdout |
Config and state files may be ESM or CommonJS. They are loaded with require(),
so an ESM file using top-level await will not load.
| Import | Contents |
|---|---|
category-colors |
The optimizer, evaluators, config factories, color utilities, and the JND report |
category-colors/report |
reportJndIssues on its own |
category-colors/evaluators |
The evaluators, without going through the main barrel |
category-colors/evaluators/saliency |
The saliency evaluator alone, for loading its lookup table on demand |
category-colors/evaluators/names |
The name-difference evaluator and its helpers; not reachable from the main entry |
category-colors/cli |
generatePalette and the CLI's formatting helpers |
The main entry is browser-safe — nothing reachable from it imports a Node
builtin — and a test enforces that. The CLI helpers, which read files from disk,
are only reachable through category-colors/cli.
The package is side-effect-free apart from one module, and says so in
package.json, so a bundler can drop whatever you do not import. The thing
worth knowing is that saliency carries a ~150 kB lookup table:
import { energy, jnd } from 'category-colors'; // table dropped
import { evaluators } from 'category-colors'; // table includedNaming evaluators individually lets them be tree-shaken. Referencing the
evaluators object does not — a bundler cannot know which keys you index, so
it has to keep all of them. If you need the object for dynamic lookup but not
saliency, build your own from named imports. To defer the table instead of
dropping it, await import('category-colors/evaluators/saliency') keeps it out
of the initial chunk until something selects it.
The names evaluator's table is larger still, about 260 kB, so it is not in
the main entry at all: category-colors/evaluators/names is the only way to
load it.
prepareInitialState(state, config)— fill, clamp, score, and choose a starting temperature.runSimulatedAnnealing(state, config)— anneal until the cutoff temperature or iteration cap.runWithOrderOptimization(state, config)— anneal, then reorder for even adjacent distances.cost(state, config)— the weighted average of every evaluator's score.costBreakdown(state, config)— the same number itemized per evaluator, inconfig.evalFunctionsorder. Each entry is{ weight, cost, weightedCost }, and theweightedCostvalues sum tocost(state, config).simulateCvd(state, type, severity)— a copy of the state with every color passed through a CVD filter.
createDefaultConfig()/createDefaultState()— starting points to spread and edit.evaluators— every evaluator, as an object, for buildingevalFunctionsdynamically. Also exported individually (energy,range,jnd,similarity,avoid,contrast,saliency).nameslives only atcategory-colors/evaluators/names.palettes— established categorical palettes (observable10,d3category10,carbon,tableau10,tableau20,colorBrewer3_10,okabeIto,petroff6,petroff8,petroff10) for comparison or as seeds. Petroff optimizes each length separately, sopetroff8is not the first eight ofpetroff10; take the one matching your color count.
deltaE(a, b, options)— perceptual distance between two colors.createColor(input, coords?)— normalize a hex string, culori object, or{ color, lockedChannels }spec into the color objects the optimizer uses.getChannels(mode)— the channel names of a color mode, in order.
Set recordHistory to collect a [iteration, cost] trace on the returned
state, which is what you need to plot convergence:
const config = { ...createDefaultConfig(), recordHistory: true, historyInterval: 100 };
const finalState = runWithOrderOptimization(prepareInitialState(state, config), config);
finalState.costHistory; // [[0, 4.21], [100, 3.88], ..., [7412, 1.03]]historyInterval defaults to maxIterations / 250. The first and last
iterations are always sampled, and order optimization appends one final point.
config.evalFunctions is an array of { function, weight, ...options }
descriptors. Weights are relative — they are normalized against their sum, not
required to add to 1. Evaluator-specific options live on the descriptor rather
than on the top-level config, so the same evaluator can appear several times
with different settings:
- Raise the weight on
energyto push colors further apart. - Raise the weight on
rangeto make the distances between colors more uniform. - Add more
jndentries with differentcvdsettings to cover more deficiencies. - Swap
similarityor changeconfig.similarityTargetto chase a reference palette.
The default evalFunctions carries four jnd terms under simulation:
protanopia, deuteranopia and tritanopia at weight 0.1 each, and grayscale at
0.05, against an unimpaired jnd term at 1. They model full dichromacy rather
than a milder anomaly, because optimizing the harder case carries the milder
one.
Those weights look too low, and are not. These terms saturate early: past a certain point they stop buying protection and only take separation from everything else. Measured over ten seeds at eight colors, against the 2.0.x weights (0.3 / 0.3 / 0.2 / 0.1 against an unimpaired 0.3):
| 2.0.x | current | |
|---|---|---|
| minimum ΔE | 19.0 | 22.6 |
| minimum ΔE, deuteranopia | 16.1 | 16.3 |
| minimum ΔE, protanopia | 16.2 | 14.9 |
| minimum ΔE, tritanopia | 17.2 | 15.6 |
| minimum ΔE, grayscale | 7.9 | 7.7 |
The number that decides whether a reader confuses two categories is the worst pair across every condition, and that is pinned to the grayscale row in both configurations — 7.9 against 7.7. The 3.6 points of ordinary separation came out of red-green headroom that was never the binding constraint.
To trade back the other way, raise the CVD weights or lower the unimpaired
jnd term. To drop conditions entirely:
const config = createDefaultConfig();
// keep only the red-green deficiencies
config.evalFunctions = config.evalFunctions.filter(
(entry) => !entry.cvd || /prot|deuter/.test(entry.cvd.type)
);Two honest notes on what these numbers mean. The deficiency filters are
culori's, implementing Machado, Oliveira and Fernandes (2009); any CVD claim
is relative to the model that produced it, so name the model when you quote a
number. And grayscale is a luminance projection standing in for print or
photocopying, not a physiological model of achromatopsia. Its score also stops
improving past a weight of about 0.05, so pushing it harder only takes
separation from the other terms.
config.colorDistance chooses the distance method and the space it is measured
in (see the culori distance docs):
const config = createDefaultConfig();
config.colorDistance = {
method: 'cmc',
space: 'oklab',
cmc: { l: 2, c: 1 },
};config.colorSpace controls the working space for initialization and mutation.
Each entry in ranges corresponds to a channel of the chosen mode, and cyclic
channels such as hue are detected automatically from the mode definition:
config.colorSpace = {
mode: 'okhsl',
ranges: [
[0, 360], // hue
[0.2, 0.8], // saturation
[0.4, 0.9], // lightness
],
};The annealing schedule itself:
- The starting temperature is derived by sampling random mutations.
initialTemperatureSamplessets how many, andinitialAcceptanceRatesets the probability of accepting a cost-increasing move at the start of the run. coolingRatemultiplies the temperature each iteration. Closer to 1 cools more slowly and runs longer.cutoffis the temperature at which the run stops.maxIterationscaps the run regardless of temperature.
Lock channels to preserve some aspect of a color while letting the rest vary —
holding a brand hue fixed while saturation and lightness move to meet a contrast
requirement, for instance. Pass colors as objects with a lockedChannels array:
const colors = [
{ color: '#ff0000', lockedChannels: [0] }, // lock hue
{ color: '#00ff00', lockedChannels: [0, 1] }, // lock hue and saturation
'#0000ff', // fully mutable
];Channel indices follow the color space mode: okhsl/hsl is
[0]=hue, [1]=saturation, [2]=lightness; oklab/lab is [0]=lightness, [1]=a, [2]=b; rgb is [0]=red, [1]=green, [2]=blue. Colors can also carry
fixedColor to exclude them from mutation entirely, or fixedOrder to pin them
in place during order optimization.
See examples/channelLocking.js.
The contrast evaluator applies exponential penalties when colors fall below a
required ratio against a background, using culori's wcagContrast():
import { evaluators } from 'category-colors';
const config = {
evalFunctions: [
{ function: evaluators.energy, weight: 0.2 },
{
function: evaluators.contrast,
weight: 0.8,
background: '#ffffff',
ratio: 3, // 3:1 non-text, 4.5:1 AA text, 7:1 AAA
checkAdjacent: false, // also check contrast between adjacent colors
},
],
};Combined with channel locking, this pulls brand colors into compliance while keeping their hue. See examples/wcagContrast.js.
avoid is the inverse of similarity: it pushes the palette away from a set of
colors. Use it to keep categorical colors clear of a background, a brand color,
or semantic colors that shouldn't be confused with data.
const config = {
evalFunctions: [
{ function: evaluators.energy, weight: 0.2 },
{
function: evaluators.avoid,
weight: 0.8,
colors: ['#e74c3c', '#2ecc71'],
radius: 0.15, // fraction of the metric's maximum distance
},
],
};Each color is charged for how far it has intruded into the radius around its nearest avoid color — a linear ramp from 1 at an exact match to 0 at the radius edge — and costs nothing outside every radius. Unlike similarity targets, avoid colors are measured exactly as given rather than coerced into the working space, since the point is distance from the actual color.
Two colors people would both call "blue" are confusable in a legend even when
they are far apart in ΔE. category-colors/evaluators/names scores that with
Heer & Stone's color-naming model, the same data behind saliency: the
evaluator returns the palette's mean pairwise name similarity, 0 to 1, and
slots into evalFunctions like any other.
import { createDefaultConfig } from 'category-colors';
import names, { nameDifference, nameTerms } from 'category-colors/evaluators/names';
const config = createDefaultConfig();
config.evalFunctions.push({ function: names, weight: 0.5 });
nameTerms('#7c0000');
// [{ term: 'red', share: 0.26 }, { term: 'darkred', share: 0.16 }, { term: 'maroon', share: 0.16 }, ...]
nameDifference('#ff0000', '#0000ff');
// ≈ 1nameDifference(a, b) is 1 - cosine of the two colors' naming vectors: 0
for the same name, 1 for no term in common. It is the quantity Colorgorical and
Palettailor optimize, so adding names makes this objective comparable with
theirs. The shipped table keeps only terms carrying at least 2% of a color's
vector, which moves name difference by 0.002 on average and 0.03 at most
against the full model.
The sibling saliency evaluator reads the other half of the same model: how
consistently people name a single color, rather than how two colors differ. It
is a cost like every other evaluator, so it returns 1 - mean saliency and
minimizing it pulls the palette toward prototypical, nameable colors. Colors
outside the model's sRGB grid have no naming data and cost the maximum.
import saliency from 'category-colors/evaluators/saliency';
config.evalFunctions.push({ function: saliency, weight: 0.3 });An evaluator takes (state, config, descriptor) and returns a cost. Read your
parameters off the descriptor — the evalFunctions entry itself — so the same
function can be used several times with different settings, and normalize the
result so its contribution stays comparable to the others:
import { converter } from 'culori';
const evaluateWarmness = (state, config, descriptor = {}) => {
const { targetHue = 30, tolerance = 60 } = descriptor;
const toHsl = converter('hsl');
const cost = state.colors.reduce((sum, color) => {
const hueDiff = Math.abs(toHsl(color).h - targetHue);
return hueDiff > tolerance ? sum + ((hueDiff - tolerance) / 180) ** 2 : sum;
}, 0);
return cost / state.colors.length;
};
export default evaluateWarmness;Exponential penalties ((threshold / value) ** 4) work better than linear ones
for hard requirements: they stay near zero while satisfied and rise steeply once
violated, which gives the annealer a clear gradient to follow.
import { reportJndIssues } from 'category-colors/report';
const result = reportJndIssues(['#ff0000', '#f20000', '#00ff00', '#0000ff'], {
distanceMethod: 'ciede2000',
distanceSpace: 'lab65',
jndThreshold: 25,
cvdSimulations: [
{ type: 'protanomaly', severity: 1 },
{ type: 'deuteranomaly', severity: 0.5 },
],
});The result holds one test per condition — the base palette plus each requested
simulation. Every test carries pairs (all pairs, with distances) and issues
(the subset below the threshold, sharing the same objects). totalIssues sums
issue counts across tests, so a pair that fails under two simulations counts
twice.
pairs is O(n²) per test and dominates the serialized size of a report; pass
includePairs: false when you only need the failures. The CLI's report
command omits it by default and takes --pairs to include it.
npm testTests use the Node.js built-in runner; there are no dev dependencies. Beyond the
unit tests, the suite checks that every exports subpath resolves to a file
that ships, and that nothing reachable from the main entry imports a Node
builtin.
The evaluators are built on published color science:
- CIEDE2000 — Sharma, G., Wu, W., and Dalal, E. N. "The CIEDE2000 Color-Difference Formula." Color Research & Application, 2005. Used by every distance-based evaluator.
- CVD simulation — Machado, G. M., Oliveira, M. M., and Fernandes, L. A. F. "A Physiologically-based Model for Simulation of Color Vision Deficiency." IEEE TVCG, 2009, via culori's deficiency filters.
- Just-noticeable difference — Stone, M., Szafir, D. A., and Setlur, V. "An Engineering Model for Color Difference as a Function of Size." Color and Imaging Conference, 2014.
- Saliency — Heer, J. and Stone, M. "Color Naming Models for Color Selection, Image Editing and Palette Design." ACM CHI, 2012. The
saliencyevaluator's lookup table is their 8,325-voxel CIELAB color-naming model, scoring how consistently people name each color; thenamesevaluator uses the same model's per-color term counts, from their c3 repository.
MIT