Dashboard-level chart palette
Docs / docs (push) Successful in 20s
Playwright Tests / test-playwright (1, 2) (push) Failing after 9m49s
Playwright Tests / test-playwright (2, 2) (push) Failing after 20s
pre-commit / pre-commit (push) Failing after 2m31s
Test Backend / test-backend (push) Failing after 48s
Compose Smoke Test / test-compose (push) Failing after 18s
Playwright Tests / merge-reports (push) Failing after 42s
Docs / docs (push) Successful in 20s
Playwright Tests / test-playwright (1, 2) (push) Failing after 9m49s
Playwright Tests / test-playwright (2, 2) (push) Failing after 20s
pre-commit / pre-commit (push) Failing after 2m31s
Test Backend / test-backend (push) Failing after 48s
Compose Smoke Test / test-compose (push) Failing after 18s
Playwright Tests / merge-reports (push) Failing after 42s
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
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart"
|
||||
import { useLiveValue } from "@/components/Flow/liveStore"
|
||||
import { messageHistoryQueryOptions, usePublishMessage } from "./queries"
|
||||
import { usePalette } from "./settings"
|
||||
import { type Series, useHeaderSlot, type WidgetProps } from "./widgets"
|
||||
|
||||
// The five-series ceiling is the chart host's, and the panel reads it here.
|
||||
@@ -120,6 +121,9 @@ function LiveChart({ widget }: WidgetProps) {
|
||||
) || DEFAULT_POINTS,
|
||||
)
|
||||
|
||||
// The dashboard's own data colours; outside one this is the whole ramp.
|
||||
const palette = usePalette()
|
||||
|
||||
const histories = useQueries({
|
||||
queries: names.map((name) => messageHistoryQueryOptions(name)),
|
||||
})
|
||||
@@ -182,6 +186,7 @@ function LiveChart({ widget }: WidgetProps) {
|
||||
<UplotChart
|
||||
labels={labels}
|
||||
plots={plots}
|
||||
palette={palette}
|
||||
{...presentation(widget.config as Record<string, unknown>)}
|
||||
/>
|
||||
)
|
||||
@@ -214,6 +219,7 @@ function QueryChart({ widget, dashboard }: WidgetProps) {
|
||||
// about less often.
|
||||
const refreshS = refreshFor(range, cfg.refresh_s)
|
||||
|
||||
const palette = usePalette()
|
||||
const publish = usePublishMessage()
|
||||
const live = useLiveValue(message || undefined)
|
||||
const [answer, setAnswer] = useState<SeriesPayload | null>(null)
|
||||
@@ -285,6 +291,7 @@ function QueryChart({ widget, dashboard }: WidgetProps) {
|
||||
(line.points ?? []).map(([ts, value]) => ({ ts, value })),
|
||||
)}
|
||||
empty="Waiting for an answer."
|
||||
palette={palette}
|
||||
{...presentation(cfg)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -78,7 +78,7 @@ import {
|
||||
usePublishDashboard,
|
||||
useSaveDashboard,
|
||||
} from "./queries"
|
||||
import { useDashboardTheme } from "./settings"
|
||||
import { PaletteProvider, useDashboardTheme } from "./settings"
|
||||
import {
|
||||
WIDGET_LABELS,
|
||||
WIDGET_SIZES,
|
||||
@@ -528,7 +528,7 @@ export function DashboardEditor({
|
||||
}
|
||||
data-testid="dashboard-canvas"
|
||||
>
|
||||
{body}
|
||||
<PaletteProvider dashboard={draft}>{body}</PaletteProvider>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
} from "@/client"
|
||||
import { cn } from "@/lib/utils"
|
||||
import "./dashboard.css"
|
||||
import { LockedProvider, useDashboardTheme } from "./settings"
|
||||
import { LockedProvider, PaletteProvider, useDashboardTheme } from "./settings"
|
||||
import { WidgetBody, WidgetFrame, widgetIssue } from "./widgets"
|
||||
|
||||
export type Dashboard = DashboardDef_Output
|
||||
@@ -304,34 +304,36 @@ export function DashboardView({
|
||||
|
||||
return (
|
||||
<LockedProvider dashboard={dashboard}>
|
||||
<div
|
||||
className={cn("widget-grid", stacked && "widget-stacked")}
|
||||
data-placed={isPlaced(widgets) || undefined}
|
||||
style={
|
||||
{
|
||||
"--widget-cols": columns,
|
||||
// The row height follows the canvas, so the CSS grid and the
|
||||
// editor's grid library cannot drift apart.
|
||||
"--row-height": `${rowHeightOf(dashboard)}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{widgets.map((widget) => (
|
||||
<div
|
||||
key={widget.id}
|
||||
style={widgetStyle(widget, columns)}
|
||||
className="widget-cell"
|
||||
>
|
||||
{renderWidget ? (
|
||||
renderWidget(widget)
|
||||
) : (
|
||||
<WidgetFrame title={widget.title} issue={widgetIssue(widget)}>
|
||||
<WidgetBody widget={widget} dashboard={dashboard.name} />
|
||||
</WidgetFrame>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<PaletteProvider dashboard={dashboard}>
|
||||
<div
|
||||
className={cn("widget-grid", stacked && "widget-stacked")}
|
||||
data-placed={isPlaced(widgets) || undefined}
|
||||
style={
|
||||
{
|
||||
"--widget-cols": columns,
|
||||
// The row height follows the canvas, so the CSS grid and the
|
||||
// editor's grid library cannot drift apart.
|
||||
"--row-height": `${rowHeightOf(dashboard)}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{widgets.map((widget) => (
|
||||
<div
|
||||
key={widget.id}
|
||||
style={widgetStyle(widget, columns)}
|
||||
className="widget-cell"
|
||||
>
|
||||
{renderWidget ? (
|
||||
renderWidget(widget)
|
||||
) : (
|
||||
<WidgetFrame title={widget.title} issue={widgetIssue(widget)}>
|
||||
<WidgetBody widget={widget} dashboard={dashboard.name} />
|
||||
</WidgetFrame>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PaletteProvider>
|
||||
</LockedProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* The dashboard palette, checked.
|
||||
*
|
||||
* ponytail: a script rather than a suite, like `color.check.ts` beside it —
|
||||
* the frontend's only runner is Playwright and picking a ramp slot does not
|
||||
* need a browser:
|
||||
*
|
||||
* cd frontend && bun run src/components/Dashboard/palette.check.ts
|
||||
*
|
||||
* The first block is the one that matters. A palette changes how *every*
|
||||
* existing dashboard is drawn, and the whole safety argument is that a
|
||||
* dashboard which names none is drawn exactly as it was before palettes
|
||||
* existed — `--chart-${(index % 5) + 1}`, the literal expression `seriesColor`
|
||||
* used. That equivalence is asserted here rather than trusted.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
import {
|
||||
CHART_SLOTS,
|
||||
DEFAULT_PALETTE,
|
||||
paletteOf,
|
||||
slotFor,
|
||||
} from "@/components/Common/UplotChart"
|
||||
|
||||
/** What a chart drew before a dashboard could name a palette. */
|
||||
const before = (index: number) => String((index % 5) + 1)
|
||||
|
||||
// A document with no palette, and every shape a broken one can arrive in:
|
||||
// all of them fall back to the ramp, and the ramp is the old expression.
|
||||
for (const stored of [undefined, null, "", [], ["9", "nonsense"], { 0: "1" }]) {
|
||||
const palette = paletteOf(stored)
|
||||
assert.deepEqual(
|
||||
palette,
|
||||
DEFAULT_PALETTE,
|
||||
`${JSON.stringify(stored)} is no palette`,
|
||||
)
|
||||
for (let index = 0; index < 23; index++) {
|
||||
assert.equal(
|
||||
slotFor(index, palette),
|
||||
before(index),
|
||||
`series ${index} of an unset palette draws as it always did`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The same, for a chart that was handed nothing at all — Health and Home draw
|
||||
// through the very same component.
|
||||
for (let index = 0; index < 23; index++) {
|
||||
assert.equal(
|
||||
slotFor(index),
|
||||
before(index),
|
||||
`series ${index} outside a dashboard`,
|
||||
)
|
||||
}
|
||||
|
||||
// A distinct subset in draw order: kept as picked, and cycled through.
|
||||
assert.deepEqual(
|
||||
paletteOf(["3", "1"]),
|
||||
["3", "1"],
|
||||
"the order picked is the order kept",
|
||||
)
|
||||
assert.deepEqual(
|
||||
[0, 1, 2, 3, 4].map((index) => slotFor(index, ["3", "1"])),
|
||||
["3", "1", "3", "1", "3"],
|
||||
"a chart with more lines than colours starts over",
|
||||
)
|
||||
|
||||
// Repeats are dropped rather than drawn: two lines on one slot could not be
|
||||
// told apart, which is the whole reason the picker offers each slot once.
|
||||
assert.deepEqual(paletteOf(["2", "2", "4"]), ["2", "4"], "a repeat is dropped")
|
||||
assert.deepEqual(paletteOf([1, 2]), ["1", "2"], "numbers name slots too")
|
||||
assert.deepEqual(paletteOf([...CHART_SLOTS].reverse()), [
|
||||
"5",
|
||||
"4",
|
||||
"3",
|
||||
"2",
|
||||
"1",
|
||||
])
|
||||
|
||||
console.log("palette: ok")
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react"
|
||||
|
||||
import type { MessageInfo, SettingDef, WidgetDef } from "@/client"
|
||||
import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker"
|
||||
import { CHART_SLOTS, paletteOf } from "@/components/Common/UplotChart"
|
||||
import {
|
||||
PANEL_SECTION,
|
||||
PanelTitle,
|
||||
@@ -1071,6 +1072,8 @@ export function DashboardPanel({
|
||||
const canvas = canvasOf(dashboard)
|
||||
const theme = settingOf(dashboard, "theme")
|
||||
const locked = settingOf(dashboard, "locked")
|
||||
const paletteSetting = settingOf(dashboard, "palette")
|
||||
const palette = paletteOf(paletteSetting.value)
|
||||
|
||||
/** Settings are a map, so one of them changing rewrites the whole of it. */
|
||||
const setSetting = (name: SettingName, setting: SettingDef) =>
|
||||
@@ -1225,6 +1228,57 @@ export function DashboardPanel({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Palette</span>
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
data-testid="dashboard-palette"
|
||||
>
|
||||
{CHART_SLOTS.map((slot) => {
|
||||
const picked = palette.includes(slot)
|
||||
return (
|
||||
<button
|
||||
key={slot}
|
||||
type="button"
|
||||
aria-pressed={picked}
|
||||
aria-label={`Colour ${slot}`}
|
||||
// The last one cannot go. An empty palette falls back to
|
||||
// the whole ramp, so the row would then contradict what
|
||||
// the charts beside it are drawing.
|
||||
disabled={picked && palette.length === 1}
|
||||
onClick={() =>
|
||||
setSetting("palette", {
|
||||
...paletteSetting,
|
||||
value: picked
|
||||
? palette.filter((other) => other !== slot)
|
||||
: [...palette, slot],
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"size-11 rounded-full transition-shadow md:size-8",
|
||||
picked &&
|
||||
"ring-2 ring-ring ring-offset-2 ring-offset-card",
|
||||
)}
|
||||
style={{
|
||||
// A token alpha rather than `opacity`, which would fade
|
||||
// the ring with the swatch.
|
||||
background: picked
|
||||
? `var(--chart-${slot})`
|
||||
: `color-mix(in srgb, var(--chart-${slot}) 30%, transparent)`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The colours this dashboard's charts draw with, taken in the order
|
||||
you pick them — a chart with more lines than colours starts over
|
||||
at the first. Each is offered once: two lines sharing a colour
|
||||
could not be told apart, and neighbouring ones are close enough
|
||||
already.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Lock</span>
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useContext } from "react"
|
||||
|
||||
import type { DashboardDef_Output, SettingDef } from "@/client"
|
||||
import { DEFAULT_PALETTE, paletteOf } from "@/components/Common/UplotChart"
|
||||
import { useLiveValue } from "@/components/Flow/liveStore"
|
||||
|
||||
/**
|
||||
@@ -35,7 +36,7 @@ export const SETTING_DTYPES: Record<string, string> = {
|
||||
}
|
||||
|
||||
/** The settings this build actually wires up. */
|
||||
export type SettingName = "theme" | "locked"
|
||||
export type SettingName = "theme" | "locked" | "palette"
|
||||
|
||||
/** What `theme` may be set to. `system` follows whatever the device says. */
|
||||
export const THEME_CHOICES = [
|
||||
@@ -99,6 +100,39 @@ export function useDashboardLocked(
|
||||
return useSetting(dashboard, "locked") === true
|
||||
}
|
||||
|
||||
const PaletteContext = createContext(DEFAULT_PALETTE)
|
||||
|
||||
/**
|
||||
* The data colours everything drawn under it uses.
|
||||
*
|
||||
* Mounted by the view *and* the editor: an editor showing the default ramp
|
||||
* while the panel beside it showed the dashboard's own palette would be a
|
||||
* preview that lies.
|
||||
*
|
||||
* Data colour only. A fault stays `--destructive` and a condition stays its
|
||||
* `ICON_COLORS` entry, because those name a state rather than tell one series
|
||||
* from another — a palette they followed could paint a failure the same blue
|
||||
* as a reading. Only the chart reads this today; a second consumer costs one
|
||||
* `usePalette()`.
|
||||
*/
|
||||
export function PaletteProvider({
|
||||
dashboard,
|
||||
children,
|
||||
}: {
|
||||
dashboard: DashboardDef_Output | undefined
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const palette = paletteOf(useSetting(dashboard, "palette"))
|
||||
return (
|
||||
<PaletteContext.Provider value={palette}>
|
||||
{children}
|
||||
</PaletteContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/** The ramp slots the dashboard around this widget draws its data with. */
|
||||
export const usePalette = () => useContext(PaletteContext)
|
||||
|
||||
const LockedContext = createContext(false)
|
||||
|
||||
/**
|
||||
|
||||
@@ -1234,7 +1234,11 @@ def seed_dashboard(api: Api) -> None:
|
||||
"value": "system",
|
||||
"message": msg(HOUSE, "panel_theme"),
|
||||
"dtype": "str",
|
||||
}
|
||||
},
|
||||
# The ends and the middle of the ramp rather than its first
|
||||
# three steps: the Power chart draws three lines, and slots
|
||||
# 1/3/5 stand further apart than 1/2/3 do.
|
||||
"palette": {"value": ["1", "3", "5"]},
|
||||
},
|
||||
"pages": PAGES,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user