Apply the chart cursor correction exactly once

uPlot writes what `cursor.move` returns back into the value it hands in next
time and calls it again on every redraw — and a chart sets its data on every
render. The correction was therefore applied repeatedly: the cursor walked
left while the pointer stood still, and the position taken at mousedown no
longer matched the one held at mouseup, which uPlot reads as a drag and
answers by swallowing the click. That is what stopped a chart tile being
selectable in the dashboard editor.

The refiner now returns its previous answer unchanged when handed it back, so
a redraw is a no-op. Lifted out of the config as `cursorRefiner` so the branch
can be checked without a browser; `cursor.check.ts` replays uPlot's own
sequences, including the press-is-not-a-drag one that regressed.

The Playwright hover test now also asserts the cursor holds its place across a
redraw, which is what the first version missed.

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 09:13:03 +02:00
co-authored by Claude Opus 5
parent 2fcaf96a83
commit b9922e7a7d
3 changed files with 130 additions and 15 deletions
+34 -12
View File
@@ -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,