Chart cursor, range picker in the header, line smoothing

A dashboard canvas is CSS-scaled to fit its panel while uPlot maps the
pointer against its own unscaled plot width, so the cursor drifted further
right the further into a chart it went. A `cursor.move` refiner divides the
visual offset back into layout pixels; unscaled hosts get a no-op.

A querying chart's range picker moves onto the frame's title line through a
new `useHeaderSlot`, giving the plot back the row it spent. The editor's drag
handle is the header, so the picker is exempted from it.

Charts can be drawn as a monotone cubic spline — uPlot's own path builder,
monotone so a smoothed line never invents a reading between two real ones.

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 06:27:48 +02:00
co-authored by Claude Opus 5
parent a225b48d0d
commit 680c6053b9
6 changed files with 153 additions and 19 deletions
+31 -3
View File
@@ -1,4 +1,4 @@
import { useState } from "react"
import { createContext, useContext, useEffect, useState } from "react"
import type { WidgetDef } from "@/client"
import { useLiveValue } from "@/components/Flow/liveStore"
@@ -214,6 +214,29 @@ export function widgetIssue(widget: WidgetDef): string | null {
return null
}
const HeaderSlot = createContext<((node: React.ReactNode) => void) | null>(null)
/**
* Draw something in the frame's header row, from inside the widget body.
*
* A widget's own chrome is state the widget holds — the window a chart is
* showing lives in the chart — while the row it belongs on is the frame's. The
* body hands the node up rather than the frame reaching down for it.
*
* `deps` name what makes the node different, as with any effect.
*/
export function useHeaderSlot(
node: React.ReactNode,
deps: React.DependencyList,
) {
const set = useContext(HeaderSlot)
useEffect(() => {
set?.(node)
return () => set?.(null)
// biome-ignore lint/correctness/useExhaustiveDependencies: the caller names what makes the node different; the node itself is a fresh element on every render, so it cannot be one.
}, deps)
}
/**
* The frame every widget sits in.
*
@@ -239,6 +262,10 @@ export function WidgetFrame({
className?: string
onClick?: React.MouseEventHandler<HTMLDivElement>
}) {
// What the body asked to have drawn up here — see `useHeaderSlot`. Held as
// state rather than portalled into the row, so a widget with no title and
// nothing to slot still costs no header at all.
const [slotted, setSlotted] = useState<React.ReactNode>(null)
return (
// A card is not a control: the click only picks it in edit mode, and every
// interactive element inside keeps its own role and keyboard handling.
@@ -254,7 +281,7 @@ export function WidgetFrame({
)}
onClick={onClick}
>
{title || actions || issue || grip ? (
{title || actions || issue || grip || slotted ? (
<div
className={cn(
"flex min-w-0 items-start justify-between gap-2",
@@ -284,6 +311,7 @@ export function WidgetFrame({
</TooltipContent>
</Tooltip>
) : null}
{slotted}
{actions}
</span>
</div>
@@ -293,7 +321,7 @@ export function WidgetFrame({
being reachable. Centring has to be `safe` — plain `center` overflows
both edges at once and puts the top of a long body out of reach. */}
<div className="flex min-h-0 flex-1 flex-col justify-center-safe overflow-y-auto">
{children}
<HeaderSlot.Provider value={setSlotted}>{children}</HeaderSlot.Provider>
</div>
</div>
)