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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user