Series colour by key shadcn ships --chart-1 through --chart-5 and no opinion about what happens at six, or about assigning them. Index a palette by array position and the same series changes colour when a filter changes the result order. npm i recharts Tokens this needs: --card, --muted-foreground, --border, --chart-1, --chart-2, --chart-3, --chart-4, --chart-5, --chart-6, --chart-7, --chart-8 ──────────────────────────────────────────────────────────────────────── // seriesColor.ts ──────────────────────────────────────────────────────────────────────── /** * Colour is a property of the series, not of its position in an array. * * Indexing by result order is the common shortcut and it means "checkout" is * blue on one panel and orange on the next, or changes colour when a filter * removes a row above it. Hashing the key fixes it for free: the same name gets * the same colour on every panel, every dashboard, every session. */ const PALETTE_SIZE = 8; /** FNV-1a. Small, stable across runs, and not trying to be a hash function. */ function hash(key: string): number { let h = 0x811c9dc5; for (let i = 0; i < key.length; i++) { h ^= key.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; } const slot = (key: string) => hash(key) % PALETTE_SIZE; /** The tokens are HSL triplets, so the reference has to be wrapped. */ const chart = (n: number) => `hsl(var(--chart-${n + 1}))`; /** One series on its own, with no others to collide with. */ export function seriesColor(key: string): string { return chart(slot(key)); } /** * A set of series that share a chart. Two keys can hash to one slot, and two * lines in one colour is the failure this pattern exists to prevent, so a * collision moves the later key (in sorted order, never result order) to the * next free slot. That bumped key can move again if the one it collided with * disappears; a series that must never move gets pinned in `overrides`. * * Past the palette limit, stop colouring. "Is there a defined behaviour past * the palette's limit?" is a checklist item, and recycling hues is the wrong * answer: everything beyond the top N is folded into one muted "other". */ export function seriesColors(keys: string[], max = PALETTE_SIZE, overrides: Record = {}) { const top = keys.slice(0, max); const rest = keys.slice(max); const taken = new Set(Object.values(overrides)); const slots = new Map(Object.entries(overrides)); for (const key of [...top].sort()) { if (slots.has(key)) continue; let s = slot(key); while (taken.has(s)) s = (s + 1) % PALETTE_SIZE; taken.add(s); slots.set(key, s); } return { assigned: top.map((key) => ({ key, color: chart(slots.get(key)!) })), other: rest.length ? { key: `${rest.length} more`, color: "hsl(var(--muted-foreground))" } : null, }; } ──────────────────────────────────────────────────────────────────────── // SeriesLines.tsx ──────────────────────────────────────────────────────────────────────── import { Line, LineChart, XAxis, YAxis, LabelList } from "recharts"; import { seriesColors } from "./seriesColor"; /** * Several series on one chart, coloured by name and labelled at the line's * end. The colour comes from the key, so `checkout` is the same hue on this * panel, the next one, and next week. The label sits where the line stops, * which removes the legend round-trip and means colour is never the only * thing telling two lines apart. * * Past `max` series the rest are summed into one muted "other". Recycling * hues would give two series one colour, and that is worse than one series * having none. */ export type Series = { key: string; values: number[] }; type Props = { series: Series[]; /** One per point, in order. Shown on the x axis. */ labels: string[]; /** Six to eight is what a person can match back across a room. */ max?: number; width?: number; height?: number; }; /** The label at the line's end. Rendered once, at the last point. */ const EndLabel = ({ x, y, value, index, last, fill }: { x?: number; y?: number; value?: unknown; index?: number; last: number; fill: string }) => index === last && typeof x === "number" && typeof y === "number" ? ( {String(value)} ) : null; export function SeriesLines({ series, labels, max = 8, width = 400, height = 160 }: Props) { const { assigned, other } = seriesColors(series.map((s) => s.key), max); const byKey = new Map(series.map((s) => [s.key, s.values])); const lines = assigned.map(({ key, color }) => ({ key, color, values: byKey.get(key)! })); if (other) { const tail = series.slice(max); lines.push({ ...other, values: labels.map((_, i) => tail.reduce((n, s) => n + s.values[i], 0)) }); } const rows = labels.map((label, i) => Object.fromEntries([["label", label], ...lines.map((l) => [l.key, l.values[i]])]), ); const last = labels.length - 1; return (
{lines.map((l) => ( {/* The series name, not the value: the label replaces the legend. */} l.key} content={} /> ))}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { SeriesLines } from "./SeriesLines"; /** * Requests per second by route over six hours. Six series, under the ceiling, * each labelled where its line ends. Add a ninth and it folds into "1 more". */ export default function Demo() { return (
); }