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 39bc711683
commit 939c14bada
7 changed files with 158 additions and 21 deletions
+51
View File
@@ -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 })