Spread a chart's default colours across the ramp

A chart that named no palette took the ramp in order, so three lines drew
slots 1, 2 and 3 — adjacent steps of a ramp that carries identity by lightness
alone, which at a 2px stroke read as close to one picture. Each count now
takes the slots furthest apart that the ramp allows: 1 -> 1, 2 -> 1/5,
3 -> 1/3/5, 4 -> 1/2/4/5. Five lines are unchanged.

Moving a default is only safe because the mechanism around it is inert: a
palette a dashboard wrote down is still drawn exactly as written, so no stored
document means anything different than it did. `palette.check.ts` now pins the
spread table and says which of the two properties is permanent.

An unnamed palette is empty rather than the whole ramp, so the settings panel
shows nothing picked when a dashboard is on automatic — and deselecting the
last colour is now the way back to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZeGnqVsf5VHQqvz4HdUhN
This commit is contained in:
2026-08-23 10:53:28 +02:00
co-authored by Claude Opus 5
parent 98bf8fb7b0
commit d5fc3fa8e8
4 changed files with 126 additions and 65 deletions
+45 -15
View File
@@ -30,8 +30,8 @@ export const CHART_SLOTS = Array.from({ length: MAX_SERIES }, (_, index) =>
String(index + 1),
)
/** What a chart draws with when nothing chose for it: the ramp, in order. */
export const DEFAULT_PALETTE = CHART_SLOTS
/** No palette named; a chart left with this spreads itself. */
export const NO_PALETTE: string[] = []
/**
* A stored palette, made safe to draw with: a distinct subset, in draw order.
@@ -42,21 +42,47 @@ export const DEFAULT_PALETTE = CHART_SLOTS
* close, and two on the *same* slot could not be told apart at all. Free
* assignment with repeats is not an improvement on this; it is the bug.
*
* Anything unrecognised, repeated or empty falls back to the whole ramp, which
* is what every chart drew before a dashboard could name a palette.
* Empty is the answer for a dashboard that names none, and for anything
* unrecognised: no palette, so the chart spreads itself — see `slotsFor`.
*/
export function paletteOf(value: unknown): string[] {
if (!Array.isArray(value)) return DEFAULT_PALETTE
if (!Array.isArray(value)) return NO_PALETTE
const slots = [...new Set(value.map(String))].filter((slot) =>
CHART_SLOTS.includes(slot),
)
return slots.length > 0 ? slots : DEFAULT_PALETTE
// One shared array for the common answer, so a dashboard that names no
// palette does not hand its readers a new one on every render.
return slots.length > 0 ? slots : NO_PALETTE
}
/** Which slot of the ramp the *index*th series takes from a palette. */
export function slotFor(index: number, palette: string[] = DEFAULT_PALETTE) {
const slots = palette.length > 0 ? palette : DEFAULT_PALETTE
return slots[index % slots.length]
/**
* What a chart of *n* lines draws with when no palette was named: the ramp
* spread, rather than its first *n* steps.
*
* Three lines used to take slots 1, 2 and 3, which are adjacent steps of a
* ramp that carries identity by lightness alone — at a 2px stroke they are
* close to one picture. The ends and the middle are as far apart as five slots
* allow, and the same reasoning gives every other count.
*
* This moved after the palette shipped, and moving it was safe *because* of
* how the palette is stored: a named palette is honoured exactly as written,
* so no stored document means anything different than it did. Only the
* unwritten default moved, and any dashboard that dislikes where it moved to
* can now name the old one. That inertness is the property worth keeping —
* not this table, which is a design decision and may move again.
*/
const SPREADS: Record<number, string[]> = {
1: ["1"],
2: ["1", "5"],
3: ["1", "3", "5"],
4: ["1", "2", "4", "5"],
5: ["1", "2", "3", "4", "5"],
}
/** The slots a chart of `count` lines draws with, named palette or not. */
export function slotsFor(count: number, palette?: string[]): string[] {
if (palette?.length) return palette
return SPREADS[Math.min(Math.max(Math.trunc(count) || 1, 1), MAX_SERIES)]
}
/**
@@ -101,8 +127,8 @@ function token(name: string): string {
.trim()
}
const seriesColor = (index: number, palette?: string[]) =>
token(`--chart-${slotFor(index, palette)}`)
const seriesColor = (index: number, slots: string[]) =>
token(`--chart-${slots[index % slots.length]}`)
/**
* Tick labels that stay distinct.
@@ -173,8 +199,9 @@ export function UplotChart({
/** A named y axis; the unit stays on the ticks. */
yLabel?: string
/** The ramp slots this chart's lines take, in order — the dashboard's own
* palette. Unset is the whole ramp, which is what a chart outside a
* dashboard (Health, Home) draws with. */
* palette. Unset, the chart spreads itself across the ramp by how many
* lines it draws, which is what one outside a dashboard (Health, Home)
* always does. */
palette?: string[]
/** Draw the lines as a monotone cubic spline rather than straight segments.
* Monotone rather than plain cubic on purpose: a spline that overshoots
@@ -223,6 +250,9 @@ export function UplotChart({
* does not re-render it. */
let told: number | null = null
const refine = cursorRefiner()
// Resolved once for the whole chart: how many lines there are is part of
// which slots they take, when nothing named them.
const slots = slotsFor(labels.length, palette)
// One builder for the whole chart: it is a factory, and the series only
// need the function it returns.
const spline = smooth ? { paths: uPlot.paths.spline?.() } : {}
@@ -295,7 +325,7 @@ export function UplotChart({
width: 2,
// Read at draw time, so a theme toggle is a redraw rather than a
// rebuilt chart.
stroke: () => seriesColor(index, palette),
stroke: () => seriesColor(index, slots),
// The cursor readout is what decides how wide the legend gets, so
// it is shortened here; a named unit is short enough to keep. Four
// figures rather than three: this is the number someone is pointing