Generating a palette
Why OKLCH and not HSL, how a categorical palette is computed rather than listed, and interpolating a ramp.
In the code: vocabulary/scales.py
Colour is a scale in the Grammar-of-Graphics sense: a mapping from data to a visual channel, exactly like position.
Sauvignon has two categorical palettes, and it is worth being straight about
which you get. When the only input is a position — fill="index" on a bar
layer, or the lanes of a timeline — the palette is computed from the count, and
that generated formula is what this article is about. When the input is a set of
category names — an ordinary fill="region" column, a legend, a pie, the
series of a grouped bar chart — it comes from a fixed ten-colour list (Vega-Lite's
default categorical scheme, tableau10).
That split is not arbitrary. A computed palette needs to know n to spread hues sensibly, which a position gives you and an open-ended set of names does not; and a curated scheme is better tested against real category counts than anything a formula produces. But two systems is one more than the design wants, and the seam shows: colour a chart by index and by column and the two do not match.
HSL lies about lightness
The obvious way to spread categories around a colour wheel is HSL: fix the saturation and lightness, walk the hue. It produces palettes where some categories shout and others vanish.
The reason is that HSL's "lightness" is a coordinate in a cylinder built on top
of sRGB, not a measure of how light a colour looks. hsl(60 100% 50%) is
yellow and hsl(240 100% 50%) is blue; both claim 50% lightness, and the yellow
is roughly four times as luminous. Rotating the hue at fixed L therefore
produces bars that seem to jump forward and back.
OKLCH fixes this. It is a cylindrical form of the Oklab space, designed so that equal steps correspond to roughly equal perceived differences: L really is perceptual lightness, C is chroma, H is hue. Hold L and C, vary H, and you get colours that differ in hue while reading as equally prominent.
Browsers take oklch() directly in CSS, so the generated value goes straight
into the SVG with no conversion.
A categorical palette as a formula
This is the computed one — reached by fill="index" and by timeline lanes.
Given category i of n, the palette is:
oklch( L% 0.1 (360 × i ÷ max(n, 3) + 30) )
with L alternating between two values as i changes. Three decisions worth
naming:
Hue spread by max(n, 3). With two categories, dividing the full circle
would put them opposite each other — 180° apart, maximally different, and often
garish. The floor of three keeps small palettes to a comfortable arc.
Alternating lightness. Adjacent hues at identical lightness can be hard to tell apart, especially for a viewer with a colour-vision deficiency. Alternating L adds a second, non-hue channel of difference, so the categories remain distinguishable in greyscale or to a dichromat. This is the single cheapest accessibility measure in the whole palette.
Chroma fixed at 0.1. Modest on purpose. High-chroma categorical palettes look impressive in isolation and are exhausting in a diagram that also has to carry text.
<diagram padding="16">
<data name="d">
<record k="one" v="5"/><record k="two" v="8"/><record k="three" v="6"/>
<record k="four" v="9"/><record k="five" v="7"/><record k="six" v="4"/>
</data>
<plot data="d" width="330" height="170" axes="x">
<bars x="k" y="v" fill="index"/>
</plot>
</diagram>
Six bars, six hues stepped around the circle at alternating lightness — no colour named anywhere in the source.
Sequential ramps are just interpolation
A numeric field maps to a sequential scale instead: pick the two endpoint colours and interpolate between them by the value's position in the domain, exactly as a linear position scale interpolates between two coordinates. A scale is a scale, and the channel it feeds is a separate question — which is the Grammar of Graphics' point.
The colour ramp is nonetheless its own function rather than a call to linear,
for a reason worth keeping: it clamps and linear does not. The difference
is where the clamp lives. A colour has nowhere to go past its endpoint —
extrapolating an RGB channel just runs off the end of the byte — so the clamp
belongs inside the scale itself. A position may legitimately extrapolate, so
linear returns whatever the domain implies and the caller decides: the chart
frame clamps marks to the plot box, so a value under a tight y-max crops to the
edge rather than painting over its neighbours. Same computation, and the same
final behaviour at the edges — reached from opposite directions.
Sauvignon interpolates in sRGB here rather than in Oklab, which is the lazier choice: a perceptual interpolation would avoid the slightly muddy midpoint you get when the endpoints are far apart in hue. For a two-tone ramp between neighbouring blues the difference is small, and it is the obvious next improvement.
Choosing which kind
The scale type is inferred, not declared: if every value in the field parses as
a finite number the field is continuous and gets a ramp, otherwise it is
categorical and gets the palette. That is the same auto-detection the
axis scales use, including the same trap — inf
parses happily as a float and has to be excluded explicitly, or one stray value
turns a category column into a numeric one with an infinite domain.
Further reading. Björn Ottosson's articles introducing Oklab and OKLCH (2020) explain what was wrong with the alternatives; Cynthia Brewer's ColorBrewer remains the reference for hand-tuned cartographic palettes, and is the thing a generated palette is measured against.