Chart cursor, range picker in the header, line smoothing

A dashboard canvas is CSS-scaled to fit its panel while uPlot maps the
pointer against its own unscaled plot width, so the cursor drifted further
right the further into a chart it went. A `cursor.move` refiner divides the
visual offset back into layout pixels; unscaled hosts get a no-op.

A querying chart's range picker moves onto the frame's title line through a
new `useHeaderSlot`, giving the plot back the row it spent. The editor's drag
handle is the header, so the picker is exempted from it.

Charts can be drawn as a monotone cubic spline — uPlot's own path builder,
monotone so a smoothed line never invents a reading between two real ones.

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 06:27:48 +02:00
co-authored by Claude Opus 5
parent a225b48d0d
commit 680c6053b9
6 changed files with 153 additions and 19 deletions
@@ -11,7 +11,7 @@ import {
import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart"
import { useLiveValue } from "@/components/Flow/liveStore"
import { messageHistoryQueryOptions, usePublishMessage } from "./queries"
import type { Series, 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.
export { MAX_SERIES }
@@ -61,13 +61,14 @@ function asPayload(value: unknown): SeriesPayload | null {
const config = (widget: WidgetProps["widget"]) =>
(widget.config ?? {}) as Record<string, unknown>
/** The unit and fixed axis a chart is drawn with, in either mode. */
/** The unit, the fixed axis and the line shape, in either mode. */
function presentation(cfg: Record<string, unknown>) {
const low = Number(cfg.y_min)
const high = Number(cfg.y_max)
return {
unit: cfg.unit ? String(cfg.unit) : undefined,
yLabel: cfg.y_label ? String(cfg.y_label) : undefined,
smooth: Boolean(cfg.smooth),
yRange:
Number.isFinite(low) && Number.isFinite(high) && low < high
? ([low, high] as [number, number])
@@ -262,22 +263,29 @@ function QueryChart({ widget, dashboard }: WidgetProps) {
// signal that something arrived — no timestamp needed beside it.
}, [live?.value, request, rangeS, intervalS])
// Drawn on the title's line rather than above the plot: a tile is short, and
// a row of its own costs the chart about a tenth of its height. Nothing to
// pick a window for until the chart is wired, so a half-configured tile says
// that and nothing else.
const bound = Boolean(request && message)
useHeaderSlot(
bound ? <RangePicker value={range} onChange={setRange} /> : null,
[range, bound],
)
if (!request || !message) {
return <p className="text-sm text-muted-foreground">Pick a message.</p>
}
const lines = (answer?.lines ?? []).slice(0, MAX_SERIES)
return (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<RangePicker value={range} onChange={setRange} />
<UplotChart
labels={lines.map((line, index) => line.label || `Series ${index + 1}`)}
plots={lines.map((line) =>
(line.points ?? []).map(([ts, value]) => ({ ts, value })),
)}
empty="Waiting for an answer."
{...presentation(cfg)}
/>
</div>
<UplotChart
labels={lines.map((line, index) => line.label || `Series ${index + 1}`)}
plots={lines.map((line) =>
(line.points ?? []).map(([ts, value]) => ({ ts, value })),
)}
empty="Waiting for an answer."
{...presentation(cfg)}
/>
)
}