/** * 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 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")