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:
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -484,7 +484,13 @@ export function DashboardEditor({
|
||||
}}
|
||||
// Only the header moves a widget, so a slider under the cursor still
|
||||
// slides and a switch still flips while the dashboard is being edited.
|
||||
dragConfig={{ handle: ".widget-grip" }}
|
||||
// The header is the handle, and a chart now draws its range
|
||||
// picker there — so that one control is exempt, or picking a
|
||||
// window would drag the tile instead.
|
||||
dragConfig={{
|
||||
handle: ".widget-grip",
|
||||
cancel: "[data-testid=range-picker]",
|
||||
}}
|
||||
resizeConfig={{ handles: ["e", "s", "se"] }}
|
||||
>
|
||||
{widgets.map((widget) => (
|
||||
|
||||
@@ -647,6 +647,24 @@ export function WidgetPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{widget.type === "chart" ? (
|
||||
<div className="grid gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
Smoothing
|
||||
<Switch
|
||||
checked={Boolean(cfg.smooth)}
|
||||
aria-label="Smoothing"
|
||||
data-testid="chart-smooth"
|
||||
onCheckedChange={(smooth) => set({ smooth })}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Curves the lines between readings. It draws them softer; it does
|
||||
not change what was measured.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{widget.type === "stat" ||
|
||||
widget.type === "gauge" ||
|
||||
widget.type === "chart" ||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react"
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
import type { WidgetDef } from "@/client"
|
||||
import { useLiveValue } from "@/components/Flow/liveStore"
|
||||
@@ -214,6 +214,29 @@ export function widgetIssue(widget: WidgetDef): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
const HeaderSlot = createContext<((node: React.ReactNode) => void) | null>(null)
|
||||
|
||||
/**
|
||||
* Draw something in the frame's header row, from inside the widget body.
|
||||
*
|
||||
* A widget's own chrome is state the widget holds — the window a chart is
|
||||
* showing lives in the chart — while the row it belongs on is the frame's. The
|
||||
* body hands the node up rather than the frame reaching down for it.
|
||||
*
|
||||
* `deps` name what makes the node different, as with any effect.
|
||||
*/
|
||||
export function useHeaderSlot(
|
||||
node: React.ReactNode,
|
||||
deps: React.DependencyList,
|
||||
) {
|
||||
const set = useContext(HeaderSlot)
|
||||
useEffect(() => {
|
||||
set?.(node)
|
||||
return () => set?.(null)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the caller names what makes the node different; the node itself is a fresh element on every render, so it cannot be one.
|
||||
}, deps)
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame every widget sits in.
|
||||
*
|
||||
@@ -239,6 +262,10 @@ export function WidgetFrame({
|
||||
className?: string
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>
|
||||
}) {
|
||||
// What the body asked to have drawn up here — see `useHeaderSlot`. Held as
|
||||
// state rather than portalled into the row, so a widget with no title and
|
||||
// nothing to slot still costs no header at all.
|
||||
const [slotted, setSlotted] = useState<React.ReactNode>(null)
|
||||
return (
|
||||
// A card is not a control: the click only picks it in edit mode, and every
|
||||
// interactive element inside keeps its own role and keyboard handling.
|
||||
@@ -254,7 +281,7 @@ export function WidgetFrame({
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{title || actions || issue || grip ? (
|
||||
{title || actions || issue || grip || slotted ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-start justify-between gap-2",
|
||||
@@ -284,6 +311,7 @@ export function WidgetFrame({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{slotted}
|
||||
{actions}
|
||||
</span>
|
||||
</div>
|
||||
@@ -293,7 +321,7 @@ export function WidgetFrame({
|
||||
being reachable. Centring has to be `safe` — plain `center` overflows
|
||||
both edges at once and puts the top of a long body out of reach. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-center-safe overflow-y-auto">
|
||||
{children}
|
||||
<HeaderSlot.Provider value={setSlotted}>{children}</HeaderSlot.Provider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user