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
|
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. */
|
/** Room for the axis ticks; uPlot measures the rest of the box itself. */
|
||||||
const PADDING: uPlot.Padding = [10, 12, 0, 0]
|
const PADDING: uPlot.Padding = [10, 12, 0, 0]
|
||||||
|
|
||||||
@@ -33,7 +72,8 @@ function token(name: string): string {
|
|||||||
.trim()
|
.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.
|
* Tick labels that stay distinct.
|
||||||
@@ -84,6 +124,7 @@ export function UplotChart({
|
|||||||
unit,
|
unit,
|
||||||
yRange,
|
yRange,
|
||||||
yLabel,
|
yLabel,
|
||||||
|
palette,
|
||||||
smooth = false,
|
smooth = false,
|
||||||
onCursor,
|
onCursor,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -102,6 +143,10 @@ export function UplotChart({
|
|||||||
yRange?: [number, number]
|
yRange?: [number, number]
|
||||||
/** A named y axis; the unit stays on the ticks. */
|
/** A named y axis; the unit stays on the ticks. */
|
||||||
yLabel?: string
|
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.
|
/** Draw the lines as a monotone cubic spline rather than straight segments.
|
||||||
* Monotone rather than plain cubic on purpose: a spline that overshoots
|
* Monotone rather than plain cubic on purpose: a spline that overshoots
|
||||||
* invents readings between two the sensor actually took. */
|
* 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)
|
const points = plots.reduce((total, plot) => total + plot.length, 0)
|
||||||
// The identity of the series set: the chart is rebuilt when it changes,
|
// 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
|
// 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.
|
// axis title are part of it — all are baked into the axes at build time —
|
||||||
const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}|${yLabel ?? ""}|${smooth}`
|
// 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
|
// 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
|
// a resize in that window (a card still settling, say) draws them anyway and
|
||||||
// throws. Waiting for the first reading avoids the state altogether.
|
// throws. Waiting for the first reading avoids the state altogether.
|
||||||
@@ -227,7 +273,7 @@ export function UplotChart({
|
|||||||
width: 2,
|
width: 2,
|
||||||
// Read at draw time, so a theme toggle is a redraw rather than a
|
// Read at draw time, so a theme toggle is a redraw rather than a
|
||||||
// rebuilt chart.
|
// rebuilt chart.
|
||||||
stroke: () => seriesColor(index),
|
stroke: () => seriesColor(index, palette),
|
||||||
// The cursor readout is what decides how wide the legend gets, so
|
// 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
|
// it is shortened here; a named unit is short enough to keep. Four
|
||||||
// figures rather than three: this is the number someone is pointing
|
// 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 { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart"
|
||||||
import { useLiveValue } from "@/components/Flow/liveStore"
|
import { useLiveValue } from "@/components/Flow/liveStore"
|
||||||
import { messageHistoryQueryOptions, usePublishMessage } from "./queries"
|
import { messageHistoryQueryOptions, usePublishMessage } from "./queries"
|
||||||
|
import { usePalette } from "./settings"
|
||||||
import { type Series, useHeaderSlot, type WidgetProps } from "./widgets"
|
import { type Series, useHeaderSlot, type WidgetProps } from "./widgets"
|
||||||
|
|
||||||
// The five-series ceiling is the chart host's, and the panel reads it here.
|
// 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,
|
) || DEFAULT_POINTS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The dashboard's own data colours; outside one this is the whole ramp.
|
||||||
|
const palette = usePalette()
|
||||||
|
|
||||||
const histories = useQueries({
|
const histories = useQueries({
|
||||||
queries: names.map((name) => messageHistoryQueryOptions(name)),
|
queries: names.map((name) => messageHistoryQueryOptions(name)),
|
||||||
})
|
})
|
||||||
@@ -182,6 +186,7 @@ function LiveChart({ widget }: WidgetProps) {
|
|||||||
<UplotChart
|
<UplotChart
|
||||||
labels={labels}
|
labels={labels}
|
||||||
plots={plots}
|
plots={plots}
|
||||||
|
palette={palette}
|
||||||
{...presentation(widget.config as Record<string, unknown>)}
|
{...presentation(widget.config as Record<string, unknown>)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -214,6 +219,7 @@ function QueryChart({ widget, dashboard }: WidgetProps) {
|
|||||||
// about less often.
|
// about less often.
|
||||||
const refreshS = refreshFor(range, cfg.refresh_s)
|
const refreshS = refreshFor(range, cfg.refresh_s)
|
||||||
|
|
||||||
|
const palette = usePalette()
|
||||||
const publish = usePublishMessage()
|
const publish = usePublishMessage()
|
||||||
const live = useLiveValue(message || undefined)
|
const live = useLiveValue(message || undefined)
|
||||||
const [answer, setAnswer] = useState<SeriesPayload | null>(null)
|
const [answer, setAnswer] = useState<SeriesPayload | null>(null)
|
||||||
@@ -285,6 +291,7 @@ function QueryChart({ widget, dashboard }: WidgetProps) {
|
|||||||
(line.points ?? []).map(([ts, value]) => ({ ts, value })),
|
(line.points ?? []).map(([ts, value]) => ({ ts, value })),
|
||||||
)}
|
)}
|
||||||
empty="Waiting for an answer."
|
empty="Waiting for an answer."
|
||||||
|
palette={palette}
|
||||||
{...presentation(cfg)}
|
{...presentation(cfg)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ import {
|
|||||||
usePublishDashboard,
|
usePublishDashboard,
|
||||||
useSaveDashboard,
|
useSaveDashboard,
|
||||||
} from "./queries"
|
} from "./queries"
|
||||||
import { useDashboardTheme } from "./settings"
|
import { PaletteProvider, useDashboardTheme } from "./settings"
|
||||||
import {
|
import {
|
||||||
WIDGET_LABELS,
|
WIDGET_LABELS,
|
||||||
WIDGET_SIZES,
|
WIDGET_SIZES,
|
||||||
@@ -528,7 +528,7 @@ export function DashboardEditor({
|
|||||||
}
|
}
|
||||||
data-testid="dashboard-canvas"
|
data-testid="dashboard-canvas"
|
||||||
>
|
>
|
||||||
{body}
|
<PaletteProvider dashboard={draft}>{body}</PaletteProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
} from "@/client"
|
} from "@/client"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import "./dashboard.css"
|
import "./dashboard.css"
|
||||||
import { LockedProvider, useDashboardTheme } from "./settings"
|
import { LockedProvider, PaletteProvider, useDashboardTheme } from "./settings"
|
||||||
import { WidgetBody, WidgetFrame, widgetIssue } from "./widgets"
|
import { WidgetBody, WidgetFrame, widgetIssue } from "./widgets"
|
||||||
|
|
||||||
export type Dashboard = DashboardDef_Output
|
export type Dashboard = DashboardDef_Output
|
||||||
@@ -304,34 +304,36 @@ export function DashboardView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<LockedProvider dashboard={dashboard}>
|
<LockedProvider dashboard={dashboard}>
|
||||||
<div
|
<PaletteProvider dashboard={dashboard}>
|
||||||
className={cn("widget-grid", stacked && "widget-stacked")}
|
<div
|
||||||
data-placed={isPlaced(widgets) || undefined}
|
className={cn("widget-grid", stacked && "widget-stacked")}
|
||||||
style={
|
data-placed={isPlaced(widgets) || undefined}
|
||||||
{
|
style={
|
||||||
"--widget-cols": columns,
|
{
|
||||||
// The row height follows the canvas, so the CSS grid and the
|
"--widget-cols": columns,
|
||||||
// editor's grid library cannot drift apart.
|
// The row height follows the canvas, so the CSS grid and the
|
||||||
"--row-height": `${rowHeightOf(dashboard)}px`,
|
// editor's grid library cannot drift apart.
|
||||||
} as React.CSSProperties
|
"--row-height": `${rowHeightOf(dashboard)}px`,
|
||||||
}
|
} as React.CSSProperties
|
||||||
>
|
}
|
||||||
{widgets.map((widget) => (
|
>
|
||||||
<div
|
{widgets.map((widget) => (
|
||||||
key={widget.id}
|
<div
|
||||||
style={widgetStyle(widget, columns)}
|
key={widget.id}
|
||||||
className="widget-cell"
|
style={widgetStyle(widget, columns)}
|
||||||
>
|
className="widget-cell"
|
||||||
{renderWidget ? (
|
>
|
||||||
renderWidget(widget)
|
{renderWidget ? (
|
||||||
) : (
|
renderWidget(widget)
|
||||||
<WidgetFrame title={widget.title} issue={widgetIssue(widget)}>
|
) : (
|
||||||
<WidgetBody widget={widget} dashboard={dashboard.name} />
|
<WidgetFrame title={widget.title} issue={widgetIssue(widget)}>
|
||||||
</WidgetFrame>
|
<WidgetBody widget={widget} dashboard={dashboard.name} />
|
||||||
)}
|
</WidgetFrame>
|
||||||
</div>
|
)}
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
</PaletteProvider>
|
||||||
</LockedProvider>
|
</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 type { MessageInfo, SettingDef, WidgetDef } from "@/client"
|
||||||
import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker"
|
import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker"
|
||||||
|
import { CHART_SLOTS, paletteOf } from "@/components/Common/UplotChart"
|
||||||
import {
|
import {
|
||||||
PANEL_SECTION,
|
PANEL_SECTION,
|
||||||
PanelTitle,
|
PanelTitle,
|
||||||
@@ -1071,6 +1072,8 @@ export function DashboardPanel({
|
|||||||
const canvas = canvasOf(dashboard)
|
const canvas = canvasOf(dashboard)
|
||||||
const theme = settingOf(dashboard, "theme")
|
const theme = settingOf(dashboard, "theme")
|
||||||
const locked = settingOf(dashboard, "locked")
|
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. */
|
/** Settings are a map, so one of them changing rewrites the whole of it. */
|
||||||
const setSetting = (name: SettingName, setting: SettingDef) =>
|
const setSetting = (name: SettingName, setting: SettingDef) =>
|
||||||
@@ -1225,6 +1228,57 @@ export function DashboardPanel({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="grid gap-2">
|
||||||
<span className={PANEL_SECTION}>Lock</span>
|
<span className={PANEL_SECTION}>Lock</span>
|
||||||
<div className="flex items-center justify-between gap-2 text-sm">
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createContext, useContext } from "react"
|
import { createContext, useContext } from "react"
|
||||||
|
|
||||||
import type { DashboardDef_Output, SettingDef } from "@/client"
|
import type { DashboardDef_Output, SettingDef } from "@/client"
|
||||||
|
import { DEFAULT_PALETTE, paletteOf } from "@/components/Common/UplotChart"
|
||||||
import { useLiveValue } from "@/components/Flow/liveStore"
|
import { useLiveValue } from "@/components/Flow/liveStore"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,7 +36,7 @@ export const SETTING_DTYPES: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The settings this build actually wires up. */
|
/** 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. */
|
/** What `theme` may be set to. `system` follows whatever the device says. */
|
||||||
export const THEME_CHOICES = [
|
export const THEME_CHOICES = [
|
||||||
@@ -99,6 +100,39 @@ export function useDashboardLocked(
|
|||||||
return useSetting(dashboard, "locked") === true
|
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)
|
const LockedContext = createContext(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user