
A color is useful when it tells the reader something that would otherwise take effort to discover. Red can mean danger, but only if the surrounding chart gives it that job. If red is also used for one category in a legend, the reader has to guess whether a red mark is unusual or merely belongs to that category. Give colors stable roles: neutral for ordinary values, a distinct accent for the thing worth noticing, and a warning color for values that actually need attention.
Choose colors according to the structure of the data. Categories need colors that are easy to tell apart, not colors arranged to imply an order. Ordered values need a progression from low to high. A midpoint that has meaning—zero, a target, or a neutral state—usually calls for two sides that diverge from it. A rainbow often looks expressive but makes equal numerical steps appear unequal, and its changes in hue can suggest boundaries where none exist.
It helps to encode the intended role before picking exact shades:
colors = {
"background": "#f3f4f6",
"ordinary": "#64748b",
"highlight": "#2563eb",
"warning": "#b91c1c",
}
Keep the highlight scarce. If every series gets a vivid color, none of them is highlighted. Use brightness and saturation as deliberately as hue: a muted blue can recede behind a saturated blue, while a pale red may be too weak to signal a serious exception. Check that labels and marks remain distinguishable against their background, and don’t rely on red versus green alone; viewers may see those hues as nearly identical, so pair color with position, shape, or a direct label.
For a chart that marks values above and below a target, make the color rule match the interpretation rather than the sign by itself:
def status_color(value, target):
if value > target:
return "#b91c1c" # above target is undesirable here
if value < target:
return "#2563eb"
return "#64748b"
Kootek Laptop Cooling Pad Cooler Stand with 5 Quiet Fans for 12"-17" Laptop | 5 Quiet & High‑Performance Laptop Fan for 360° Full Coverage, USB‑Powered, 2 Extra USB Hubs, 6 Adjustable Height, Blue LED
$25.98 (as of October 24, 2026 20:23 GMT +00:00 - More infoProduct prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on [relevant Amazon Site(s), as applicable] at the time of purchase will apply to the purchase of this product.)Building a colormap from simple rules
A continuous colormap can be built from a few anchor colors. Each anchor says what a particular part of the range should look like; values between anchors are interpolated. Put a neutral color at the meaningful midpoint when the map represents deviation from a target, rather than letting the midpoint inherit an arbitrary shade.
def parse_hex(color):
color = color.removeprefix("#")
if len(color) != 6:
raise ValueError("expected a six-digit hex color")
return tuple(int(color[i:i + 2], 16) for i in (0, 2, 4))
def to_hex(rgb):
return "#" + "".join(f"{channel:02x}" for channel in rgb)
def color_at(position, stops):
"""Return the interpolated color at a position from zero to one."""
if not stops:
raise ValueError("at least one color stop is required")
positions = [stop[0] for stop in stops]
if any(a >= b for a, b in zip(positions, positions[1:])):
raise ValueError("stop positions must be strictly increasing")
if positions[0] < 0 or positions[-1] > 1:
raise ValueError("stop positions must be between zero and one")
position = min(1, max(0, position))
if position <= positions[0]:
return stops[0][1]
if position >= positions[-1]:
return stops[-1][1]
for (left_pos, left_color), (right_pos, right_color) in zip(stops, stops[1:]):
if position <= right_pos:
fraction = (position - left_pos) / (right_pos - left_pos)
left = parse_hex(left_color)
right = parse_hex(right_color)
rgb = tuple(
round(a + fraction * (b - a))
for a, b in zip(left, right)
)
return to_hex(rgb)
raise AssertionError("unreachable")
The stops encode the rule, while the interpolation code stays generic. A diverging map might use a cool color at the low end, a light neutral at the midpoint, and a warm color at the high end:
stops = [
(0.0, "#2166ac"),
(0.5, "#f7f7f7"),
(1.0, "#b2182b"),
]
color = color_at(0.72, stops)
Normalize data to the interval the rule expects, and make the normalization explicit. If zero is the center, divide by the largest absolute deviation; if the values are strongly skewed, quantiles may give the map more useful resolution than a linear scale. Clip only when saturation at the ends is meaningful. Otherwise, retain an out-of-range marker or report the value instead of silently making a large value look like the endpoint.
Interpolation in ordinary RGB is convenient, but equal steps in RGB do not look equally spaced to people. For a small chart, a few hand-chosen stops are often sufficient. For a map used across many plots, generate the intermediate colors in a perceptually uniform space such as OKLab, then convert back to RGB and clamp the channels. The rule should still be visible in the stops: changing the interpolation space should not change which values are supposed to be neutral, emphasized, or exceptional.
Testing the map against real data
Test the map on the distribution it will actually encode. A symmetric scale centered at zero is a reasonable rule for deviations, but it can waste most of its colors when a few outliers are much larger than the rest. Inspect quantiles and the fraction of observations that would land beyond the endpoints before choosing the range:
import numpy as np
def inspect_deviations(values, center=0.0, limit_quantile=0.98):
values = np.asarray(values, dtype=float)
finite = values[np.isfinite(values)]
if finite.size == 0:
raise ValueError("no finite values")
deviations = finite - center
limit = np.quantile(np.abs(deviations), limit_quantile)
if limit == 0:
raise ValueError("all finite values equal the center")
print("quantiles:", np.quantile(
deviations, [0, 0.01, 0.25, 0.5, 0.75, 0.99, 1]
))
print("outside scale:", np.mean(np.abs(deviations) > limit))
return limit
The quantile is a proposed endpoint, not permission to discard the observations beyond it. If the outside fraction is large, the chosen range will conceal meaningful differences. If it's small, clipping may be acceptable when the chart marks saturated values clearly and reports their actual numbers on inspection.
Apply the normalization to the data and keep missing values separate from the scale. A missing observation is not a low value, a neutral value, or an endpoint; give it its own treatment so it cannot acquire a misleading color:
def map_deviations(values, stops, center=0.0, limit=1.0):
values = np.asarray(values, dtype=float)
result = np.empty(values.shape, dtype=object)
missing = ~np.isfinite(values)
result[missing] = "#d1d5db"
scaled = (values[~missing] - center) / (2 * limit) + 0.5
result[~missing] = [
color_at(position, stops)
for position in np.clip(scaled, 0, 1)
]
return result
Then look at the colors where the data are dense, not only at evenly spaced points in the theoretical range. A map may have a beautiful midpoint and endpoints while making the common values nearly indistinguishable. Sampling the empirical distribution reveals where many observations collapse into similar shades:
def inspect_color_samples(values, stops, center=0.0, limit=1.0):
values = np.asarray(values, dtype=float)
finite = values[np.isfinite(values)]
samples = np.quantile(finite, [0, 0.1, 0.25, 0.5, 0.75, 0.9, 1])
for value in samples:
position = np.clip((value - center) / (2 * limit) + 0.5, 0, 1)
print(f"{value: .4g} {color_at(position, stops)}")
Inspect the result in the chart, with its real labels, background, and neighboring marks. A color that works as a large isolated swatch can disappear in a thin line or a dense scatter plot. Check whether the midpoint is actually neutral, whether nearby values remain distinguishable at the chart’s displayed size, and whether the legend’s endpoints match the normalization used for the marks.
