Dashboard-level chart palette

A dashboard names the data colours its charts draw with: an ordered, distinct
subset of the `--chart-1…5` ramp, stored on the settings channel that already
carries `theme` and `locked`. No backend or client change — `SettingDef.value`
is free-form and a name this build does not wire up is left alone rather than
refused.

Naming none is the whole ramp, which resolves to the identical token per
series as the `--chart-${(index % 5) + 1}` charts drew with before, so every
existing dashboard is unaffected. `palette.check.ts` asserts that equivalence
rather than trusting it.

Distinct slots, not free assignment with repeats: only slot 1 against slot 5
clears 3:1 within the ramp, so two traces on one slot could not be told apart.
Status colour is deliberately outside the palette — a fault is `--destructive`
because of what it means, not because of where it sits.

The provider is mounted by the editor as well as the view, so picking a
palette repaints the charts beside the panel instead of describing them.

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 08:45:31 +02:00
co-authored by Claude Opus 5
parent 148d50f2bd
commit 2fcaf96a83
7 changed files with 260 additions and 36 deletions
+50 -4
View File
@@ -20,6 +20,45 @@ import { si } from "@/lib/utils"
*/
export const MAX_SERIES = 5
/**
* The ramp's slots, as a palette names them.
*
* The whole set one may draw from — five, for the same reason `MAX_SERIES` is
* five, because they are the same five.
*/
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
/**
* A stored palette, made safe to draw with: a distinct subset, in draw order.
*
* Distinct on purpose, and worth keeping that way. Within the ramp only slot 1
* against slot 5 clears the 3:1 guideline for non-text — 3.28 light, 3.37 dark,
* measured in `BarWidget.tsx` — so two traces on neighbouring slots are already
* 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.
*/
export function paletteOf(value: unknown): string[] {
if (!Array.isArray(value)) return DEFAULT_PALETTE
const slots = [...new Set(value.map(String))].filter((slot) =>
CHART_SLOTS.includes(slot),
)
return slots.length > 0 ? slots : DEFAULT_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]
}
/** Room for the axis ticks; uPlot measures the rest of the box itself. */
const PADDING: uPlot.Padding = [10, 12, 0, 0]
@@ -33,7 +72,8 @@ function token(name: string): string {
.trim()
}
const seriesColor = (index: number) => token(`--chart-${(index % 5) + 1}`)
const seriesColor = (index: number, palette?: string[]) =>
token(`--chart-${slotFor(index, palette)}`)
/**
* Tick labels that stay distinct.
@@ -84,6 +124,7 @@ export function UplotChart({
unit,
yRange,
yLabel,
palette,
smooth = false,
onCursor,
onSelect,
@@ -102,6 +143,10 @@ export function UplotChart({
yRange?: [number, number]
/** 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?: string[]
/** Draw the lines as a monotone cubic spline rather than straight segments.
* Monotone rather than plain cubic on purpose: a spline that overshoots
* invents readings between two the sensor actually took. */
@@ -126,8 +171,9 @@ export function UplotChart({
const points = plots.reduce((total, plot) => total + plot.length, 0)
// The identity of the series set: the chart is rebuilt when it changes,
// while a new reading only sets its data. The unit, the fixed range and the
// axis title are part of it — all are baked into the axes at build time.
const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}|${yLabel ?? ""}|${smooth}`
// axis title are part of it — all are baked into the axes at build time
// and so are the line shape and the palette, which the series close over.
const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}|${yLabel ?? ""}|${smooth}|${palette?.join("") ?? ""}`
// uPlot leaves its axes half-initialised while the scales have no range, and
// a resize in that window (a card still settling, say) draws them anyway and
// throws. Waiting for the first reading avoids the state altogether.
@@ -227,7 +273,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),
stroke: () => seriesColor(index, palette),
// 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