diff --git a/frontend/src/components/Common/UplotChart.tsx b/frontend/src/components/Common/UplotChart.tsx index c95b034..3ee65e0 100644 --- a/frontend/src/components/Common/UplotChart.tsx +++ b/frontend/src/components/Common/UplotChart.tsx @@ -84,6 +84,7 @@ export function UplotChart({ unit, yRange, yLabel, + smooth = false, onCursor, onSelect, }: { @@ -101,6 +102,10 @@ export function UplotChart({ yRange?: [number, number] /** A named y axis; the unit stays on the ticks. */ 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. */ onCursor?: (ts: number | null) => void /** 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, // 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 ?? ""}` + const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}|${yLabel ?? ""}|${smooth}` // 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. @@ -142,6 +147,9 @@ export function UplotChart({ /** The x value the page was last told about, so a move within one bucket * does not re-render it. */ 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) => self.cursor.idx == null ? null : Number(self.data[0][self.cursor.idx]) @@ -150,7 +158,21 @@ export function UplotChart({ width: element.clientWidth || 320, height: canvasHeight(element), 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: { live: true, // 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. spanGaps: true, points: { show: false }, + ...spline, })), ], }, diff --git a/frontend/src/components/Dashboard/ChartWidget.tsx b/frontend/src/components/Dashboard/ChartWidget.tsx index e660672..38928f4 100644 --- a/frontend/src/components/Dashboard/ChartWidget.tsx +++ b/frontend/src/components/Dashboard/ChartWidget.tsx @@ -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 -/** 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) { 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 ? : null, + [range, bound], + ) + if (!request || !message) { return

Pick a message.

} const lines = (answer?.lines ?? []).slice(0, MAX_SERIES) return ( -
- - line.label || `Series ${index + 1}`)} - plots={lines.map((line) => - (line.points ?? []).map(([ts, value]) => ({ ts, value })), - )} - empty="Waiting for an answer." - {...presentation(cfg)} - /> -
+ line.label || `Series ${index + 1}`)} + plots={lines.map((line) => + (line.points ?? []).map(([ts, value]) => ({ ts, value })), + )} + empty="Waiting for an answer." + {...presentation(cfg)} + /> ) } diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index 92f67b5..1d09e4f 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -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) => ( diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx index 0652ca0..96ed5ef 100644 --- a/frontend/src/components/Dashboard/panels.tsx +++ b/frontend/src/components/Dashboard/panels.tsx @@ -647,6 +647,24 @@ export function WidgetPanel({ ) : null} + {widget.type === "chart" ? ( +
+
+ Smoothing + set({ smooth })} + /> +
+

+ Curves the lines between readings. It draws them softer; it does + not change what was measured. +

+
+ ) : null} + {widget.type === "stat" || widget.type === "gauge" || widget.type === "chart" || diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx index 1999cb3..5ca2683 100644 --- a/frontend/src/components/Dashboard/widgets.tsx +++ b/frontend/src/components/Dashboard/widgets.tsx @@ -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 }) { + // 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(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 ? (
) : null} + {slotted} {actions}
@@ -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. */}
- {children} + {children}
) diff --git a/frontend/tests/widgets.spec.ts b/frontend/tests/widgets.spec.ts index 9cbbf1f..3776d15 100644 --- a/frontend/tests/widgets.spec.ts +++ b/frontend/tests/widgets.spec.ts @@ -401,6 +401,57 @@ test("a chart's axis title is kept", async ({ page }) => { 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) { test.describe(`${scheme} theme`, () => { test.use({ colorScheme: scheme }) diff --git a/scripts/seed_demo.py b/scripts/seed_demo.py index 21e3a80..39338ba 100644 --- a/scripts/seed_demo.py +++ b/scripts/seed_demo.py @@ -901,6 +901,9 @@ ENERGY_WIDGETS = [ "history": {"points": 720}, "unit": " kW", "y_label": "kW", + # Twelve hours of readings a minute apart: the shape is what is + # being read, not any single sample. + "smooth": True, # Fixed, so the picture does not rescale under you every time the # sun goes behind something. Negative is exporting. "y_min": -7, @@ -914,8 +917,8 @@ ENERGY_WIDGETS = [ "layout": at(7, 0, 6, 4), "config": { # The other kind of chart: it asks, rather than reading the ring - # the engine keeps. The range picker at the top of the tile is what - # publishes the request. + # the engine keeps. The range picker on the tile's title line is + # what publishes the request. "source": "query", "request": msg(HISTORY, "chart_request"), "request_dtype": "record",