On a CSS-scaled dashboard panel uPlot reads the pointer in visual pixels and measures it against its own unscaled plot width. Refining the result in cursor.move left uPlot's earlier arithmetic wrong: cacheMouse snaps an offset within 1px of the plot edge to the plot width, and on a panel scaled up the raw visual offset passes that edge at 1/drawn of the way across, so the readout stopped advancing partway over the chart and stuck to the last point. cursor.bind wraps the three events that carry a position and hands uPlot a corrected clientX/clientY, so every step after it — the snap, the drag detection, the redraw path — comes out right by construction. It is also stateless, which removes the cursor.move memo and the drag.click workaround that the mousedown/mouseup mismatch needed. cursor.check.ts now replays uPlot 1.6.32's own pipeline against the shipped cursor config, for panels scaled both down and up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZeGnqVsf5VHQqvz4HdUhN
167 lines
5.6 KiB
TypeScript
167 lines
5.6 KiB
TypeScript
/**
|
|
* The pointer on a CSS-scaled panel, 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 two versions of this shipped a regression the existing
|
|
* checks did not see. `pointer()` below replays uPlot 1.6.32's own pipeline —
|
|
* `cacheMouse` and `updateCursor`, transcribed with line numbers — against the
|
|
* cursor the component actually configures, so a correction made at the wrong
|
|
* seam fails here rather than on a live dashboard.
|
|
*/
|
|
|
|
import assert from "node:assert/strict"
|
|
import type uPlot from "uplot"
|
|
|
|
import { CURSOR } from "./UplotChart"
|
|
|
|
/** The plot's own, unscaled size: what uPlot measures everything against. */
|
|
const WIDTH = 525
|
|
const HEIGHT = 300
|
|
/** Somewhere off the viewport corner, so an offset is never a coordinate. */
|
|
const LEFT = 100
|
|
const TOP = 40
|
|
|
|
/** uPlot's `incrRound` (uPlot.esm.js:519). */
|
|
const incrRound = (num: number, incr: number) => Math.round(num / incr) * incr
|
|
|
|
/**
|
|
* A pointer over a chart whose panel is painted at `drawn`.
|
|
*
|
|
* uPlot's own sequence: the browser calls the listener `cursor.bind` returned,
|
|
* `cacheMouse` turns the event into a plot offset and snaps it at the edges,
|
|
* and `updateCursor` runs `cursor.move` over the result — again on every
|
|
* redraw, feeding back what it last returned.
|
|
*/
|
|
function pointer(drawn: number) {
|
|
const over = { clientWidth: WIDTH }
|
|
const self = {
|
|
rect: {
|
|
left: LEFT,
|
|
top: TOP,
|
|
width: WIDTH * drawn,
|
|
height: HEIGHT * drawn,
|
|
},
|
|
over,
|
|
}
|
|
const refine =
|
|
CURSOR.move ??
|
|
((_self: uPlot, left: number, top: number) =>
|
|
[left, top] as [number, number])
|
|
const bind = CURSOR.bind as Required<uPlot.Cursor.Bind>
|
|
|
|
let seen = { clientX: 0, clientY: 0 }
|
|
const listen = (event: keyof uPlot.Cursor.Bind) =>
|
|
bind[event](
|
|
self as unknown as uPlot,
|
|
over as unknown as HTMLElement,
|
|
((corrected: MouseEvent) => {
|
|
seen = corrected
|
|
return null
|
|
}) as uPlot.Cursor.MouseListener,
|
|
) as uPlot.Cursor.MouseListener
|
|
|
|
/** A press or a move at `across` of the way over the plot's *visual* box. */
|
|
const at = (event: keyof uPlot.Cursor.Bind, across: number) => {
|
|
listen(event)({
|
|
clientX: self.rect.left + self.rect.width * across,
|
|
clientY: self.rect.top + self.rect.height / 2,
|
|
target: over,
|
|
button: 0,
|
|
} as unknown as MouseEvent)
|
|
return [seen.clientX - self.rect.left, seen.clientY - self.rect.top]
|
|
}
|
|
|
|
/** uPlot's edge snap, against the layout plot box (uPlot.esm.js:5776). */
|
|
const snap = ([left, top]: number[]) => [
|
|
left <= 1 || left >= WIDTH - 1 ? incrRound(left, WIDTH) : left,
|
|
top <= 1 || top >= HEIGHT - 1 ? incrRound(top, HEIGHT) : top,
|
|
]
|
|
|
|
let mouseLeft1 = -10
|
|
let mouseTop1 = -10
|
|
|
|
const self_ = self as unknown as uPlot
|
|
return {
|
|
/** `updateCursor` (uPlot.esm.js:5305). Returns where the cursor lands. */
|
|
redraw() {
|
|
;[mouseLeft1, mouseTop1] = refine(self_, mouseLeft1, mouseTop1)
|
|
return mouseLeft1
|
|
},
|
|
/** A move to `across` of the visual box, and the redraw that follows it. */
|
|
move(across: number) {
|
|
;[mouseLeft1, mouseTop1] = snap(at("mousemove", across))
|
|
return this.redraw()
|
|
},
|
|
/** `mouseLeft0`: refined, never snapped (uPlot.esm.js:5786). */
|
|
press(across: number) {
|
|
return refine(self_, ...(at("mousedown", across) as [number, number]))[0]
|
|
},
|
|
/** `mouseLeft1` at mouseup: snapped, never refined (uPlot.esm.js:5790). */
|
|
release(across: number) {
|
|
return snap(at("mouseup", across))[0]
|
|
},
|
|
/** The pointer leaving the plot, parked off it (uPlot.esm.js:5942). */
|
|
leave() {
|
|
mouseLeft1 = -10
|
|
mouseTop1 = -10
|
|
return this.redraw()
|
|
},
|
|
}
|
|
}
|
|
|
|
// Both directions. Every earlier version of this only ever tried a panel
|
|
// scaled down, which is the half that hid the bug: scaled *up*, the raw offset
|
|
// runs past the plot's own width and uPlot snaps it to the last point.
|
|
for (const drawn of [0.43, 0.64, 1, 1.13, 1.6]) {
|
|
const at = pointer(drawn)
|
|
|
|
// A sweep across the whole visual box has to walk the whole plot — the last
|
|
// point included. Short of it is the drift; stuck is the snap.
|
|
assert.equal(at.move(0), 0, `${drawn}: the left edge is the plot's left edge`)
|
|
let last = -1
|
|
for (let step = 1; step <= 20; step++) {
|
|
const left = at.move(step / 20)
|
|
assert.ok(left > last, `${drawn}: step ${step} did not advance`)
|
|
last = left
|
|
}
|
|
assert.equal(
|
|
last,
|
|
WIDTH,
|
|
`${drawn}: the sweep stopped short of the last point`,
|
|
)
|
|
|
|
// The user's case: a live chart calls `setData` constantly, and every one of
|
|
// those redraws re-runs `cursor.move` over its own last answer.
|
|
const held = at.move(0.5)
|
|
for (let redraw = 0; redraw < 25; redraw++) {
|
|
assert.equal(
|
|
at.redraw(),
|
|
held,
|
|
`${drawn}: redraw ${redraw} moved the cursor`,
|
|
)
|
|
}
|
|
|
|
// uPlot calls it a drag when the press and the release disagree
|
|
// (uPlot.esm.js:2954), and swallows the click that ends one — which is a
|
|
// chart tile that cannot be selected in the dashboard editor.
|
|
assert.equal(
|
|
at.press(0.5),
|
|
at.release(0.5),
|
|
`${drawn}: a press where the pointer already was reads as a drag`,
|
|
)
|
|
}
|
|
|
|
// Leaving the plot: uPlot parks the cursor off-plot at -10, and it has to stay
|
|
// off-plot however the panel is scaled, or the line would stick where it was.
|
|
{
|
|
const at = pointer(0.64)
|
|
at.move(0.5)
|
|
assert.ok(at.leave() < 0, "the parked cursor stays off the plot")
|
|
}
|
|
|
|
console.log("cursor: ok")
|