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:
@@ -84,6 +84,7 @@ export function UplotChart({
|
|||||||
unit,
|
unit,
|
||||||
yRange,
|
yRange,
|
||||||
yLabel,
|
yLabel,
|
||||||
|
smooth = false,
|
||||||
onCursor,
|
onCursor,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
@@ -101,6 +102,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
|
||||||
|
/** 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. */
|
||||||
|
smooth?: boolean
|
||||||
/** The x value under the pointer, and null once it leaves the plot. */
|
/** The x value under the pointer, and null once it leaves the plot. */
|
||||||
onCursor?: (ts: number | null) => void
|
onCursor?: (ts: number | null) => void
|
||||||
/** The x value clicked, or null for a click that landed on no point. */
|
/** The x value clicked, or null for a click that landed on no point. */
|
||||||
@@ -122,7 +127,7 @@ export function UplotChart({
|
|||||||
// 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 ?? ""}`
|
const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}|${yLabel ?? ""}|${smooth}`
|
||||||
// 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.
|
||||||
@@ -142,6 +147,9 @@ export function UplotChart({
|
|||||||
/** The x value the page was last told about, so a move within one bucket
|
/** The x value the page was last told about, so a move within one bucket
|
||||||
* does not re-render it. */
|
* does not re-render it. */
|
||||||
let told: number | null = null
|
let told: number | null = null
|
||||||
|
// One builder for the whole chart: it is a factory, and the series only
|
||||||
|
// need the function it returns.
|
||||||
|
const spline = smooth ? { paths: uPlot.paths.spline?.() } : {}
|
||||||
const under = (self: uPlot) =>
|
const under = (self: uPlot) =>
|
||||||
self.cursor.idx == null ? null : Number(self.data[0][self.cursor.idx])
|
self.cursor.idx == null ? null : Number(self.data[0][self.cursor.idx])
|
||||||
|
|
||||||
@@ -150,7 +158,21 @@ export function UplotChart({
|
|||||||
width: element.clientWidth || 320,
|
width: element.clientWidth || 320,
|
||||||
height: canvasHeight(element),
|
height: canvasHeight(element),
|
||||||
padding: PADDING,
|
padding: PADDING,
|
||||||
cursor: { y: false },
|
cursor: {
|
||||||
|
y: false,
|
||||||
|
// A dashboard canvas is CSS-scaled to fit its panel, and uPlot maps
|
||||||
|
// the pointer with `clientX - rect.left` — visual pixels — against
|
||||||
|
// its own unscaled plot width. On a scaled panel the cursor then
|
||||||
|
// drifts further right the further in it goes. Dividing by the ratio
|
||||||
|
// the element is actually drawn at puts it back in layout pixels;
|
||||||
|
// unscaled, the ratio is 1 and this is a no-op.
|
||||||
|
move: (self, left, top) => {
|
||||||
|
const drawn = self.rect.width / self.over.clientWidth
|
||||||
|
return drawn > 0 && drawn !== 1
|
||||||
|
? [left / drawn, top / drawn]
|
||||||
|
: [left, top]
|
||||||
|
},
|
||||||
|
},
|
||||||
legend: {
|
legend: {
|
||||||
live: true,
|
live: true,
|
||||||
// Mounted in its own row under the plot rather than inside it: a
|
// Mounted in its own row under the plot rather than inside it: a
|
||||||
@@ -216,6 +238,7 @@ export function UplotChart({
|
|||||||
// holes, and a line with a hole per point is not a line.
|
// holes, and a line with a hole per point is not a line.
|
||||||
spanGaps: true,
|
spanGaps: true,
|
||||||
points: { show: false },
|
points: { show: false },
|
||||||
|
...spline,
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +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 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.
|
// The five-series ceiling is the chart host's, and the panel reads it here.
|
||||||
export { MAX_SERIES }
|
export { MAX_SERIES }
|
||||||
@@ -61,13 +61,14 @@ function asPayload(value: unknown): SeriesPayload | null {
|
|||||||
const config = (widget: WidgetProps["widget"]) =>
|
const config = (widget: WidgetProps["widget"]) =>
|
||||||
(widget.config ?? {}) as Record<string, unknown>
|
(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>) {
|
function presentation(cfg: Record<string, unknown>) {
|
||||||
const low = Number(cfg.y_min)
|
const low = Number(cfg.y_min)
|
||||||
const high = Number(cfg.y_max)
|
const high = Number(cfg.y_max)
|
||||||
return {
|
return {
|
||||||
unit: cfg.unit ? String(cfg.unit) : undefined,
|
unit: cfg.unit ? String(cfg.unit) : undefined,
|
||||||
yLabel: cfg.y_label ? String(cfg.y_label) : undefined,
|
yLabel: cfg.y_label ? String(cfg.y_label) : undefined,
|
||||||
|
smooth: Boolean(cfg.smooth),
|
||||||
yRange:
|
yRange:
|
||||||
Number.isFinite(low) && Number.isFinite(high) && low < high
|
Number.isFinite(low) && Number.isFinite(high) && low < high
|
||||||
? ([low, high] as [number, number])
|
? ([low, high] as [number, number])
|
||||||
@@ -262,14 +263,22 @@ function QueryChart({ widget, dashboard }: WidgetProps) {
|
|||||||
// signal that something arrived — no timestamp needed beside it.
|
// signal that something arrived — no timestamp needed beside it.
|
||||||
}, [live?.value, request, rangeS, intervalS])
|
}, [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) {
|
if (!request || !message) {
|
||||||
return <p className="text-sm text-muted-foreground">Pick a message.</p>
|
return <p className="text-sm text-muted-foreground">Pick a message.</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = (answer?.lines ?? []).slice(0, MAX_SERIES)
|
const lines = (answer?.lines ?? []).slice(0, MAX_SERIES)
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
|
||||||
<RangePicker value={range} onChange={setRange} />
|
|
||||||
<UplotChart
|
<UplotChart
|
||||||
labels={lines.map((line, index) => line.label || `Series ${index + 1}`)}
|
labels={lines.map((line, index) => line.label || `Series ${index + 1}`)}
|
||||||
plots={lines.map((line) =>
|
plots={lines.map((line) =>
|
||||||
@@ -278,6 +287,5 @@ function QueryChart({ widget, dashboard }: WidgetProps) {
|
|||||||
empty="Waiting for an answer."
|
empty="Waiting for an answer."
|
||||||
{...presentation(cfg)}
|
{...presentation(cfg)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -484,7 +484,13 @@ export function DashboardEditor({
|
|||||||
}}
|
}}
|
||||||
// Only the header moves a widget, so a slider under the cursor still
|
// 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.
|
// 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"] }}
|
resizeConfig={{ handles: ["e", "s", "se"] }}
|
||||||
>
|
>
|
||||||
{widgets.map((widget) => (
|
{widgets.map((widget) => (
|
||||||
|
|||||||
@@ -647,6 +647,24 @@ export function WidgetPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 === "stat" ||
|
||||||
widget.type === "gauge" ||
|
widget.type === "gauge" ||
|
||||||
widget.type === "chart" ||
|
widget.type === "chart" ||
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react"
|
import { createContext, useContext, useEffect, useState } from "react"
|
||||||
|
|
||||||
import type { WidgetDef } from "@/client"
|
import type { WidgetDef } from "@/client"
|
||||||
import { useLiveValue } from "@/components/Flow/liveStore"
|
import { useLiveValue } from "@/components/Flow/liveStore"
|
||||||
@@ -214,6 +214,29 @@ export function widgetIssue(widget: WidgetDef): string | null {
|
|||||||
return 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.
|
* The frame every widget sits in.
|
||||||
*
|
*
|
||||||
@@ -239,6 +262,10 @@ export function WidgetFrame({
|
|||||||
className?: string
|
className?: string
|
||||||
onClick?: React.MouseEventHandler<HTMLDivElement>
|
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 (
|
return (
|
||||||
// A card is not a control: the click only picks it in edit mode, and every
|
// 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.
|
// interactive element inside keeps its own role and keyboard handling.
|
||||||
@@ -254,7 +281,7 @@ export function WidgetFrame({
|
|||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{title || actions || issue || grip ? (
|
{title || actions || issue || grip || slotted ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-w-0 items-start justify-between gap-2",
|
"flex min-w-0 items-start justify-between gap-2",
|
||||||
@@ -284,6 +311,7 @@ export function WidgetFrame({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
{slotted}
|
||||||
{actions}
|
{actions}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -293,7 +321,7 @@ export function WidgetFrame({
|
|||||||
being reachable. Centring has to be `safe` — plain `center` overflows
|
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. */}
|
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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -401,6 +401,57 @@ test("a chart's axis title is kept", async ({ page }) => {
|
|||||||
await expect(field).toHaveValue("kW")
|
await expect(field).toHaveValue("kW")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cursor has to land under the pointer.
|
||||||
|
*
|
||||||
|
* A panel is drawn at its own pixel size and CSS-scaled to fit the screen it
|
||||||
|
* landed on, while uPlot maps the pointer against its own unscaled plot width
|
||||||
|
* — so without a correction the cursor lags further behind the further into
|
||||||
|
* the chart it is. The cursor line's box is in screen pixels, which is the
|
||||||
|
* same space the mouse was moved in, so the two are directly comparable.
|
||||||
|
*
|
||||||
|
* The readings are published with the panel already open: nothing keeps a ring
|
||||||
|
* for this tile, so the live tail is what puts a line on it.
|
||||||
|
*/
|
||||||
|
test("a chart's cursor follows the pointer", async ({ page }) => {
|
||||||
|
await openPanel(page)
|
||||||
|
// The socket carries the tail, so it has to be listening first.
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
for (const value of [40, 60, 50, 70]) {
|
||||||
|
await publish(page, w("level"), value)
|
||||||
|
await page.waitForTimeout(250)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chart = page.getByTestId("widget-frame").filter({ hasText: "Trend" })
|
||||||
|
const over = chart.locator(".u-over")
|
||||||
|
await over.waitFor({ timeout: 15000 })
|
||||||
|
const box = (await over.boundingBox()) as {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worth asserting only while the panel really is scaled, which is what the
|
||||||
|
// correction is for.
|
||||||
|
const drawnAt = await over.evaluate(
|
||||||
|
(el) => el.getBoundingClientRect().width / el.clientWidth,
|
||||||
|
)
|
||||||
|
expect(drawnAt, "a panel is scaled to fit the screen").toBeLessThan(0.95)
|
||||||
|
|
||||||
|
// Well inside: uPlot snaps the last pixel at either edge to the edge itself.
|
||||||
|
const x = box.x + box.width * 0.6
|
||||||
|
await page.mouse.move(x, box.y + box.height / 2)
|
||||||
|
|
||||||
|
const cursor = chart.locator(".u-cursor-x")
|
||||||
|
await expect(cursor).toBeVisible()
|
||||||
|
const line = (await cursor.boundingBox()) as { x: number }
|
||||||
|
expect(
|
||||||
|
Math.abs(line.x - x),
|
||||||
|
`the cursor is drawn at ${line.x.toFixed(1)}, the pointer is at ${x.toFixed(1)}`,
|
||||||
|
).toBeLessThan(3)
|
||||||
|
})
|
||||||
|
|
||||||
for (const scheme of ["light", "dark"] as const) {
|
for (const scheme of ["light", "dark"] as const) {
|
||||||
test.describe(`${scheme} theme`, () => {
|
test.describe(`${scheme} theme`, () => {
|
||||||
test.use({ colorScheme: scheme })
|
test.use({ colorScheme: scheme })
|
||||||
|
|||||||
Reference in New Issue
Block a user