Fold the brain and health screens into Home
Home is the one overview now: the brain graph flat across the top, the flow switches, then the health sections. The /brain and /health routes and their sidebar entries are gone. - charts report their cursor and clicks, so hovering one filters the list beside it to that minute and a click pins it until Escape or Clear - chart values round to about three significant digits, the legend mounts under the plot so it can wrap without leaving the card, and axis ticks shorten past a thousand - the embedded brain leaves the wheel to the page rather than zooming - number fields no longer draw their up/down spinner (NOTEPAD) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt
This commit is contained in:
@@ -4,6 +4,7 @@ import "uplot/dist/uPlot.min.css"
|
||||
|
||||
import type { HistoryPoint } from "@/client"
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
import { compact } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* How many lines one chart carries.
|
||||
@@ -16,11 +17,8 @@ export const MAX_SERIES = 5
|
||||
/** Room for the axis ticks; uPlot measures the rest of the box itself. */
|
||||
const PADDING: uPlot.Padding = [10, 12, 0, 0]
|
||||
|
||||
/** The legend sits under the canvas, so the canvas has to leave it room. */
|
||||
const LEGEND_HEIGHT = 26
|
||||
|
||||
const canvasHeight = (element: HTMLElement) =>
|
||||
Math.max(60, (element.clientHeight || 180) - LEGEND_HEIGHT)
|
||||
Math.max(60, element.clientHeight || 180)
|
||||
|
||||
/** A token, resolved for the canvas — which cannot read CSS variables. */
|
||||
function token(name: string): string {
|
||||
@@ -31,6 +29,17 @@ function token(name: string): string {
|
||||
|
||||
const seriesColor = (index: number) => token(`--chart-${(index % 5) + 1}`)
|
||||
|
||||
/**
|
||||
* An axis tick, kept short.
|
||||
*
|
||||
* The gutter the ticks are drawn in has a fixed width, so a grouped "15,000"
|
||||
* is clipped to something that reads as a different number entirely.
|
||||
*/
|
||||
const tick = (value: number) =>
|
||||
Math.abs(value) >= 1000
|
||||
? `${+(value / 1000).toPrecision(3)}k`
|
||||
: compact(value)
|
||||
|
||||
/** The series joined onto one x axis, which is what uPlot draws. */
|
||||
function table(plots: HistoryPoint[][]): uPlot.AlignedData {
|
||||
return uPlot.join(
|
||||
@@ -52,22 +61,39 @@ function table(plots: HistoryPoint[][]): uPlot.AlignedData {
|
||||
* that arrives. Its own legend doubles as the hover readout, so the cursor
|
||||
* tells you what each line was worth at that moment — and with more than one
|
||||
* line a legend is required anyway.
|
||||
*
|
||||
* `onCursor` / `onSelect` report where the pointer is in the data, so a page
|
||||
* can tie a list to the chart without reaching into the uPlot instance.
|
||||
*/
|
||||
export function UplotChart({
|
||||
labels,
|
||||
plots,
|
||||
empty = "Nothing has come through yet.",
|
||||
onCursor,
|
||||
onSelect,
|
||||
}: {
|
||||
/** One label per series; the set of them is the chart's identity. */
|
||||
labels: string[]
|
||||
/** The points of each series, in the same order as `labels`. */
|
||||
plots: HistoryPoint[][]
|
||||
empty?: string
|
||||
/** The x value under the pointer, and null once it leaves the plot. */
|
||||
onCursor?: (ts: number | null) => void
|
||||
/** The x value clicked, or null for a click that landed on no point. */
|
||||
onSelect?: (ts: number | null) => void
|
||||
}) {
|
||||
const host = useRef<HTMLDivElement>(null)
|
||||
const legend = useRef<HTMLDivElement>(null)
|
||||
const chart = useRef<uPlot | null>(null)
|
||||
// The chart outlives a render, so its handlers are read through a ref
|
||||
// rather than baked into the config it was built with.
|
||||
const report = useRef({ onCursor, onSelect })
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
report.current = { onCursor, onSelect }
|
||||
})
|
||||
|
||||
const points = plots.reduce((total, plot) => total + plot.length, 0)
|
||||
// The identity of the series set: the chart is rebuilt when it changes,
|
||||
// while a new reading only sets its data.
|
||||
@@ -88,6 +114,11 @@ export function UplotChart({
|
||||
ticks: { stroke: () => token("--border"), width: 1 },
|
||||
font: `11px ${getComputedStyle(element).fontFamily}`,
|
||||
}
|
||||
/** 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 under = (self: uPlot) =>
|
||||
self.cursor.idx == null ? null : Number(self.data[0][self.cursor.idx])
|
||||
|
||||
const plot = new uPlot(
|
||||
{
|
||||
@@ -95,11 +126,38 @@ export function UplotChart({
|
||||
height: canvasHeight(element),
|
||||
padding: PADDING,
|
||||
cursor: { y: false },
|
||||
legend: { live: true },
|
||||
legend: {
|
||||
live: true,
|
||||
// Mounted in its own row under the plot rather than inside it: a
|
||||
// legend that wraps then takes height from the chart instead of
|
||||
// spilling past the bottom of the card.
|
||||
mount: (_self, element) => legend.current?.appendChild(element),
|
||||
},
|
||||
hooks: {
|
||||
setCursor: [
|
||||
(self) => {
|
||||
const ts = under(self)
|
||||
if (ts === told) return
|
||||
told = ts
|
||||
report.current.onCursor?.(ts)
|
||||
},
|
||||
],
|
||||
ready: [
|
||||
(self) => {
|
||||
self.over.addEventListener("click", () =>
|
||||
report.current.onSelect?.(under(self)),
|
||||
)
|
||||
},
|
||||
],
|
||||
},
|
||||
scales: { x: { time: true } },
|
||||
axes: [
|
||||
{ ...axis, size: 28 },
|
||||
{ ...axis, size: 46 },
|
||||
{
|
||||
...axis,
|
||||
size: 46,
|
||||
values: (_self: uPlot, ticks: number[]) => ticks.map(tick),
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
@@ -109,6 +167,10 @@ export function UplotChart({
|
||||
// Read at draw time, so a theme toggle is a redraw rather than a
|
||||
// rebuilt chart.
|
||||
stroke: () => seriesColor(index),
|
||||
// The cursor readout is what decides how wide the legend gets, so
|
||||
// it is rounded here and the unit named in the card's title.
|
||||
value: (_self: uPlot, raw: number) =>
|
||||
Number.isFinite(raw) ? compact(raw) : "--",
|
||||
// Series arrive on their own clocks; a joined table is mostly
|
||||
// holes, and a line with a hole per point is not a line.
|
||||
spanGaps: true,
|
||||
@@ -134,6 +196,9 @@ export function UplotChart({
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
plot.destroy()
|
||||
// The legend was moved out of the plot's root, so destroying it leaves
|
||||
// the table behind.
|
||||
legend.current?.replaceChildren()
|
||||
chart.current = null
|
||||
}
|
||||
}, [key, ready])
|
||||
@@ -151,13 +216,18 @@ export function UplotChart({
|
||||
}, [resolvedTheme])
|
||||
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div ref={host} className="absolute inset-0" />
|
||||
{points === 0 ? (
|
||||
<p className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{empty}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div ref={host} className="absolute inset-0" />
|
||||
{points === 0 ? (
|
||||
<p className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{empty}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{/* Kept at the legend's resting height, so the plot does not resize
|
||||
under the pointer the first time a reading arrives. */}
|
||||
<div ref={legend} className="min-h-6 shrink-0" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user