Correct the chart pointer at the event boundary, not in cursor.move
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
This commit is contained in:
@@ -86,32 +86,95 @@ export function slotsFor(count: number, palette?: string[]): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* The pointer, put back into layout pixels on a CSS-scaled panel.
|
||||
* What the pointer correction reads off a chart.
|
||||
*
|
||||
* Structural rather than `uPlot` itself, so the check beside this file can
|
||||
* hand it a plain object.
|
||||
*/
|
||||
type Painted = {
|
||||
rect: { left: number; top: number; width: number }
|
||||
over: { clientWidth: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* A pointer event, put back into the panel's own pixels.
|
||||
*
|
||||
* 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.
|
||||
* fit the screen, while uPlot works in layout pixels throughout: it takes
|
||||
* `clientX - rect.left` — visual pixels — and measures it against its own
|
||||
* unscaled plot width. The cursor drifts further the deeper into a scaled
|
||||
* chart it goes, and on a panel scaled *up* it does worse than drift. Past
|
||||
* `1 / drawn` of the way across, the visual offset has passed the layout plot
|
||||
* width, and uPlot's own edge snap (`cacheMouse`, uPlot.esm.js:5776) rounds it
|
||||
* to that width outright: the readout stops advancing partway across and
|
||||
* sticks to the last point.
|
||||
*
|
||||
* 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.
|
||||
* Correcting the event, before uPlot has done any arithmetic with it, is what
|
||||
* makes that whole chain come out right — the snap included. It is also
|
||||
* stateless, so it cannot be applied twice. `cursor.move`, the other seam, is
|
||||
* handed its own output back and re-run on every redraw, so anything refined
|
||||
* there can only stay right by recognising its own last answer.
|
||||
*
|
||||
* `drawn` is the ratio the element is painted at; unscaled it is 1 and this is
|
||||
* a no-op. One ratio for both axes: the panel is scaled uniformly.
|
||||
*/
|
||||
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
|
||||
export function inLayoutPixels<E extends { clientX: number; clientY: number }>(
|
||||
self: Painted,
|
||||
event: E,
|
||||
): E {
|
||||
const { rect } = self
|
||||
const drawn = rect.width / self.over.clientWidth
|
||||
if (!(drawn > 0) || drawn === 1) return event
|
||||
return new Proxy(event, {
|
||||
get(target, key) {
|
||||
if (key === "clientX")
|
||||
return rect.left + (event.clientX - rect.left) / drawn
|
||||
if (key === "clientY")
|
||||
return rect.top + (event.clientY - rect.top) / drawn
|
||||
const value = Reflect.get(target, key)
|
||||
// The event's own methods still need the event as their receiver.
|
||||
return typeof value === "function" ? value.bind(target) : value
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* uPlot's own listener filters — `filtBtn0` and `filtTarg` — with the pointer
|
||||
* corrected on the way through. Only the three events that carry a position
|
||||
* are wrapped; the others read no coordinates.
|
||||
*/
|
||||
const binder =
|
||||
(button: boolean) =>
|
||||
(
|
||||
self: Painted,
|
||||
target: object,
|
||||
handle: (event: MouseEvent) => void,
|
||||
onlyTarget = true,
|
||||
) =>
|
||||
(event: MouseEvent) => {
|
||||
if (button && event.button !== 0) return
|
||||
if (onlyTarget && event.target !== target) return
|
||||
handle(inLayoutPixels(self, event))
|
||||
}
|
||||
|
||||
/** The cursor every chart is built with; exported so the check can drive it. */
|
||||
export const CURSOR: uPlot.Cursor = {
|
||||
y: false,
|
||||
// uPlot's shipped types drop the binder's fourth `onlyTarg` argument, which
|
||||
// it does pass — the document-wide mouseup binding depends on it.
|
||||
bind: {
|
||||
mousedown: binder(true),
|
||||
mouseup: binder(true),
|
||||
mousemove: binder(false),
|
||||
} as unknown as uPlot.Cursor.Bind,
|
||||
drag: {
|
||||
// No drag-to-zoom. `setData` re-ranges the scales from the data and runs
|
||||
// on every render, so a dragged range was erased by the next reading — all
|
||||
// it ever did here was flash a selection box over a live chart.
|
||||
x: false,
|
||||
y: false,
|
||||
setScale: false,
|
||||
},
|
||||
}
|
||||
|
||||
/** Room for the axis ticks; uPlot measures the rest of the box itself. */
|
||||
@@ -249,7 +312,6 @@ 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()
|
||||
// Resolved once for the whole chart: how many lines there are is part of
|
||||
// which slots they take, when nothing named them.
|
||||
const slots = slotsFor(labels.length, palette)
|
||||
@@ -264,32 +326,7 @@ export function UplotChart({
|
||||
width: element.clientWidth || 320,
|
||||
height: canvasHeight(element),
|
||||
padding: PADDING,
|
||||
cursor: {
|
||||
y: false,
|
||||
// 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),
|
||||
drag: {
|
||||
// No drag-to-zoom. `setData` below re-ranges the scales from the
|
||||
// data and runs on every render, so a dragged range was erased by
|
||||
// the next reading — all it ever did here was flash a selection
|
||||
// box over a live chart.
|
||||
//
|
||||
// Which also settles the guard that comes with it. uPlot swallows
|
||||
// the click that ends a drag, and decides one happened by
|
||||
// comparing the position it took at mousedown — refined through
|
||||
// `cursor.move` — against the one it holds at mouseup, which it
|
||||
// re-reads raw and never refines. On a scaled panel those never
|
||||
// agree, so *every* click on a plot read as a drag and was
|
||||
// stopped: a chart tile could not be selected by clicking the
|
||||
// chart. With no drag there is no click to protect.
|
||||
x: false,
|
||||
y: false,
|
||||
setScale: false,
|
||||
click: () => {},
|
||||
},
|
||||
},
|
||||
cursor: CURSOR,
|
||||
legend: {
|
||||
live: true,
|
||||
// Mounted in its own row under the plot rather than inside it: a
|
||||
|
||||
Reference in New Issue
Block a user