Sequential and diverging ramps Two ramps with different jobs. Sequential runs one direction and must stay monotonic in lightness; diverging has a real zero in the middle and arms that match. shadcn has neither, and the difference is not decorative. Tokens this needs: --card, --muted-foreground, --scale-seq-1, --scale-seq-2, --scale-seq-3, --scale-seq-4, --scale-seq-5, --scale-div-neg-2, --scale-div-neg-1, --scale-div-mid, --scale-div-pos-1, --scale-div-pos-2 The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // scale.ts ──────────────────────────────────────────────────────────────────────── /** * The ramp follows the shape of the quantity, not the other way round. * * Sequential for a magnitude with one direction—requests, bytes, latency. * Diverging for a quantity with a meaningful zero—variance to forecast, * change against a baseline. Using a diverging ramp on a sequential quantity * invents a midpoint the data does not have, and readers will look for meaning * in it because midpoints usually mean something. */ export type ScaleKind = "sequential" | "diverging" | "cyclical"; // The tokens are HSL triplets, so they are only a colour inside hsl(). const SEQUENTIAL = [1, 2, 3, 4, 5].map((i) => `hsl(var(--scale-seq-${i}))`); const DIVERGING = ["neg-2", "neg-1", "mid", "pos-1", "pos-2"].map((s) => `hsl(var(--scale-div-${s}))`); /** Up the sequential ramp and back down it, so the ends meet. Hour of day, * angle, phase: a sequential ramp here puts a hard edge at midnight. */ const CYCLICAL = [...SEQUENTIAL, ...SEQUENTIAL.slice(0, -1).reverse()]; export const RAMP: Record = { sequential: SEQUENTIAL, diverging: DIVERGING, cyclical: CYCLICAL }; /** * Clamp to a percentile rather than to the extremes. * * One outlier otherwise consumes the whole ramp and every other value collapses * into the first step. The legend has to say the domain was clamped, or the * reader believes the top of the scale is the maximum. */ export function clampDomain(values: number[], p = 0.98): [number, number] { const sorted = [...values].sort((a, b) => a - b); const at = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * (sorted.length - 1)))]; return [at(1 - p), at(p)]; } export function rampColor(value: number, kind: ScaleKind, domain: [number, number]): string { const steps = RAMP[kind]; if (kind === "diverging") { // The midpoint is a real zero, never the mean of the data. const span = Math.max(Math.abs(domain[0]), Math.abs(domain[1])) || 1; const t = Math.max(-1, Math.min(1, value / span)); return steps[Math.round((t + 1) / 2 * (steps.length - 1))]; } const t = (value - domain[0]) / (domain[1] - domain[0] || 1); return steps[Math.round(Math.max(0, Math.min(1, t)) * (steps.length - 1))]; } ──────────────────────────────────────────────────────────────────────── // ColorScale.tsx ──────────────────────────────────────────────────────────────────────── import { clampDomain, rampColor, type ScaleKind } from "./scale"; /** * A legend with real values on it, not a bare gradient. Each value gets its * swatch from the ramp the quantity's shape calls for, and the caption says * the two things a reader cannot see: where the midpoint sits, and whether * the domain was clamped. Colour answers "roughly"; the numbers are here for * anyone who needs "exactly". */ export function ColorScale({ values, kind, format = String, clampAt = 0.98, onPick }: { values: number[]; /** Sequential, diverging or cyclical. Required, because the default is how a diverging ramp ends up on a magnitude. */ kind: ScaleKind; format?: (v: number) => string; /** Percentile the domain is clamped to. 1 means the extremes, and the caption says so either way. */ clampAt?: number; onPick?: (value: number) => void; }) { const domain = clampDomain(values, clampAt); const lo = Math.min(...values), hi = Math.max(...values); const clamped = domain[0] > lo || domain[1] < hi; const caption = kind === "diverging" ? `Midpoint at zero · ${format(-Math.max(Math.abs(domain[0]), Math.abs(domain[1])))} to ${format(Math.max(Math.abs(domain[0]), Math.abs(domain[1])))}` : kind === "cyclical" ? `Wraps · ${format(domain[0])} to ${format(domain[1])} and back` : `Low to high · ${format(domain[0])} to ${format(domain[1])}`; return (
{caption} {clamped && · clamped at p{Math.round(clampAt * 100)}, {format(hi)} shows as {format(domain[1])}}
{values.map((v, i) => (
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { ColorScale } from "./ColorScale"; /** * Seven week-on-week changes, every one of them positive. The ramp is * diverging because the quantity has a real zero, and the legend says the * midpoint sits there, so all seven land on one arm instead of the first * three being coloured as losses. Seven values have no outlier to clamp. */ export default function Demo() { return (
`${v > 0 ? "+" : ""}${v}`} clampAt={1} onPick={(v) => console.log("filter to", v)} />
); }