diff --git a/frontend/src/components/Common/UplotChart.tsx b/frontend/src/components/Common/UplotChart.tsx index 4b70a94..362d0f4 100644 --- a/frontend/src/components/Common/UplotChart.tsx +++ b/frontend/src/components/Common/UplotChart.tsx @@ -59,6 +59,35 @@ export function slotFor(index: number, palette: string[] = DEFAULT_PALETTE) { return slots[index % slots.length] } +/** + * The pointer, put back into layout pixels on a CSS-scaled panel. + * + * A dashboard canvas is drawn at its panel's own pixel size and CSS-scaled to + * fit the screen, while 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 into the chart it goes. + * `drawn` is the ratio the element is actually painted at; unscaled it is 1 + * and every call here is a no-op. + * + * Applied exactly once per position, which is the whole trick. uPlot writes + * what this returns back into the value it hands in next time and calls it + * again on every redraw — and a chart redraws on every render. Dividing twice + * would walk the cursor left while the pointer stood still, and would leave + * the position taken at mousedown disagreeing with the one at mouseup, which + * uPlot reads as a drag: it then swallows the click, and a chart in the + * dashboard editor cannot be selected at all. + */ +export function cursorRefiner() { + let placed: [number, number] = [-10, -10] + return (drawn: number, left: number, top: number): [number, number] => { + if (!(drawn > 0) || drawn === 1) return [left, top] + // Handed back what it was last given: already in layout pixels. + if (left === placed[0] && top === placed[1]) return placed + placed = [left / drawn, top / drawn] + return placed + } +} + /** Room for the axis ticks; uPlot measures the rest of the box itself. */ const PADDING: uPlot.Padding = [10, 12, 0, 0] @@ -193,6 +222,7 @@ 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 + const refine = cursorRefiner() // 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?.() } : {} @@ -206,18 +236,10 @@ export function UplotChart({ padding: PADDING, 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] - }, + // See `cursorRefiner`: the panel is scaled, uPlot's pointer maths + // is not, and this must run exactly once per position. + move: (self, left, top) => + refine(self.rect.width / self.over.clientWidth, left, top), }, legend: { live: true, diff --git a/frontend/src/components/Common/cursor.check.ts b/frontend/src/components/Common/cursor.check.ts new file mode 100644 index 0000000..d2185e7 --- /dev/null +++ b/frontend/src/components/Common/cursor.check.ts @@ -0,0 +1,81 @@ +/** + * The cursor refiner, checked. + * + * ponytail: a script rather than a suite, like the two beside `ColorWidget` — + * the frontend's only runner is Playwright and this is arithmetic: + * + * cd frontend && bun run src/components/Common/cursor.check.ts + * + * Written because the first version of this shipped a regression Playwright + * only caught two tests later. uPlot hands `cursor.move` its own output back + * and calls it again on every redraw, so refining a second time walked the + * cursor left and — worse — made a still pointer look like a drag, which uPlot + * answers by swallowing the click. The sequences below are uPlot's own: + * `mouseLeft1` is set raw from the event, refined in place by `updateCursor`, + * and then fed back in unchanged by every later redraw. + */ + +import assert from "node:assert/strict" + +import { cursorRefiner } from "./UplotChart" + +/** What a panel scaled to fit a 1280x720 window is drawn at. */ +const DRAWN = 0.64 + +// Unscaled — Health and Home — is untouched, whatever the sequence. +{ + const refine = cursorRefiner() + for (const at of [0, 12, 300, -10]) { + assert.deepEqual(refine(1, at, at), [at, at], `${at} is left alone at 1:1`) + } +} + +// One pointer move, then the redraws a live dashboard does on every render. +// The position must not move while the pointer does not. +{ + const refine = cursorRefiner() + const [left, top] = refine(DRAWN, 128, 64) + assert.equal(left, 200, "128 visual pixels is 200 layout pixels at 0.64") + assert.equal(top, 100) + for (let redraw = 0; redraw < 25; redraw++) { + const again = refine(DRAWN, left, top) + assert.deepEqual(again, [left, top], `redraw ${redraw} moved the cursor`) + } +} + +// What a click is, in uPlot's own terms: `mouseLeft1` follows the move and +// every redraw after it, `mouseLeft0` is refined from the raw press on its +// own, and uPlot calls it a drag when the two disagree. They must not, or the +// click is swallowed and the dashboard never hears it. +{ + const refine = cursorRefiner() + let [left1, top1] = refine(DRAWN, 128, 64) + for (let redraw = 0; redraw < 3; redraw++) { + ;[left1, top1] = refine(DRAWN, left1, top1) + } + const [left0, top0] = refine(DRAWN, 128, 64) + assert.deepEqual( + [left1, top1], + [left0, top0], + "a press where the pointer already was is not a drag", + ) +} + +// A real drag still reads as one. +{ + const refine = cursorRefiner() + const from = refine(DRAWN, 128, 64) + const to = refine(DRAWN, 192, 64) + assert.notDeepEqual(to, from, "a pointer that moved has moved") +} + +// Leaving the plot: uPlot parks the cursor off-plot at -10, and it has to stay +// off-plot however it is scaled, or the line would stick where it was. +{ + const refine = cursorRefiner() + refine(DRAWN, 128, 64) + const [left, top] = refine(DRAWN, -10, -10) + assert.ok(left < 0 && top < 0, "the parked cursor stays off the plot") +} + +console.log("cursor: ok") diff --git a/frontend/tests/widgets.spec.ts b/frontend/tests/widgets.spec.ts index 3776d15..1dde2e2 100644 --- a/frontend/tests/widgets.spec.ts +++ b/frontend/tests/widgets.spec.ts @@ -445,10 +445,22 @@ test("a chart's cursor follows the pointer", async ({ page }) => { const cursor = chart.locator(".u-cursor-x") await expect(cursor).toBeVisible() - const line = (await cursor.boundingBox()) as { x: number } + const at = async () => ((await cursor.boundingBox()) as { x: number }).x + const line = await at() expect( - Math.abs(line.x - x), - `the cursor is drawn at ${line.x.toFixed(1)}, the pointer is at ${x.toFixed(1)}`, + Math.abs(line - x), + `the cursor is drawn at ${line.toFixed(1)}, the pointer is at ${x.toFixed(1)}`, + ).toBeLessThan(3) + + // And it stays there. The chart sets its data on every render, which makes + // uPlot recompute the cursor from the position it already holds — so a + // correction applied twice would walk the line left while nothing moved. + await publish(page, w("level"), 55) + await page.waitForTimeout(1500) + const settled = await at() + expect( + Math.abs(settled - x), + `after a redraw the cursor is at ${settled.toFixed(1)}, the pointer at ${x.toFixed(1)}`, ).toBeLessThan(3) })