Files
observer/web/src/components/Sparkline.tsx
T
grenadeandClaude Opus 5 7e5c2de936
deploy / build (push) Successful in 6m36s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 16s
feat: sparkline history under the headline tiles
Adds `GET /v1/chains/{chain}/series` and a sparkline under network hashrate,
difficulty, block time and miners. Height gets none — it only goes up, and a
straight diagonal reports nothing.

The chart is bucketed by height, not by time: every point is the same number of
blocks, so every point carries equal statistical weight and a stretch nobody
recorded stays the same width on the axis as one that was. A bucket is the
window over fifty, which makes a full window exactly fifty buckets — that is
what lets the rolling miner count at the last point be the same number printed
in the tile above it, rather than a per-bucket count that would sit well below
it. Buckets are anchored to absolute height for the same reason `miner_series`
bins from the epoch: anchored to the tip instead, every boundary slides by one
on every block and the chart shifts under a reader watching it.

Interval comes from `authored_at`, never `observed_at`. A gap fill writes a
whole batch of observation times within the same second, so deltas taken from
it read as a chain producing hundreds of blocks a second — the trap
`RollingWindow`'s `at_tip` flag exists to avoid, which until now lived only in
memory. It is a column as of `0002`, so the distinction survives a restart and
`observed_at` is interpretable at all.

Each point's interval is a three-bucket moving average. The mean of one
bucket's gaps still carries real Poisson error — 72 blocks of a 15 s interval
lands at ±1.8 s — and across fifty points that draws a chain that appears to
change size every few minutes and does not.

Fixes a wrong number found on the way in: `pallet_qpow` retargets in
`on_finalize`, so `QPoWApi_get_difficulty` at the latest state is the
difficulty for the *next* block, not the one just seen. Every stored difficulty
was off by one block — invisible, because a gradual retarget still looks
plausible. It is now asked at `header.parent_hash`, the same call the miner
made when it built the block. A node that has pruned that state gets `None`
rather than the current value: substituting it is exactly how a gap fill would
stamp today's difficulty across a stretch of old heights. The CLI backfill had
the same bug at greater scale, copying one reading across an entire range.

Unknowns are drawn as breaks in the line, never interpolated: an outage, a
difficulty a pruned node cannot supply, a point without a full window of
authors behind it. A chart that guesses across what was never recorded is the
same class of lie as a hashrate divided by a sync speed, and just as hard to
catch afterwards.

Read against the dataviz skill first, per CLAUDE.md. Single series, one hue;
emphasis on the newest point is `--data-bright`, a lighter step of the same
bronze, because the usual "current period in the accent" convention is the one
change that would break this palette. The hover readout borrows each tile's
note line — five floating tooltips across a row of 180px tiles is a pile, not a
hover layer — and the same readout is on keyboard focus and in the aria-label,
since the line does not start at zero and its height is not a quantity anyone
should be estimating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-08 14:39:27 +03:00

180 lines
6.7 KiB
TypeScript

/**
* The trend under a stat tile.
*
* A stat tile answers "what is it now" and cannot answer "and is that high".
* The sparkline is the smallest mark that can: one series, one hue, no axes, no
* legend — the tile's label already names what is plotted and the tile's value
* already prints where the line ends.
*
* Three things it deliberately does not do:
*
* **It does not start at zero.** A sparkline is read for shape, not magnitude,
* and a hashrate that has moved 3% would be a flat line on a zero baseline. The
* band is the window's own min to max, which is why the accessible label and
* the hover readout both carry real numbers — the height of the line is not a
* quantity a reader should be estimating.
*
* **It does not join across a gap.** A `null` is a value this observer does not
* know — an outage, a pruned difficulty, a window not yet full — and a line
* drawn across it would assert history that was never recorded. Each run of
* known values is its own path.
*
* **It does not introduce a second colour.** Bronze and crimson are adjacent
* hues that fail CVD separation as a categorical pair (see `index.css`), so the
* "current period in the accent" convention would be exactly the change that
* breaks this palette. Emphasis on the newest point is a lighter step of the
* same hue instead.
*
* The SVG is measured rather than scaled to fit: one unit stays one CSS pixel,
* so the 2px stroke is 2px on every screen and the crosshair lands under the
* pointer instead of near it.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
const HEIGHT = 30
/**
* Room for the terminal marker and the stroke's round cap at all four edges.
* Without the horizontal half the newest point — the one the eye goes to — is
* sliced in two by the viewport boundary.
*/
const PAD = 4
/** Used until the container is measured, and if ResizeObserver is absent. */
const FALLBACK_WIDTH = 140
/** One plotted value and how to say it out loud. */
export interface SparkPoint {
/** `null` where the observer has no figure for this bucket. */
value: number | null
/** The readout for this point: a formatted value, and when it was. */
label: string
}
function useMeasuredWidth(): [React.RefObject<HTMLDivElement | null>, number] {
const ref = useRef<HTMLDivElement>(null)
const [width, setWidth] = useState(FALLBACK_WIDTH)
useEffect(() => {
const node = ref.current
if (!node) return
const observer = new ResizeObserver(([entry]) => {
if (entry) setWidth(Math.max(60, entry.contentRect.width))
})
observer.observe(node)
return () => observer.disconnect()
}, [])
return [ref, width]
}
export function Sparkline({
points,
label,
onProbe,
}: {
points: SparkPoint[]
/** What the series is, for the accessible name. The tile's label. */
label: string
/** Called with the hovered or focused index, and `null` on leaving. */
onProbe: (index: number | null) => void
}) {
const [box, width] = useMeasuredWidth()
const [active, setActive] = useState<number | null>(null)
const probe = useCallback(
(index: number | null) => {
setActive(index)
onProbe(index)
},
[onProbe],
)
const known = points.filter((p) => p.value !== null)
const values = known.map((p) => p.value as number)
const low = Math.min(...values)
const high = Math.max(...values)
const span = high - low
const plot = Math.max(1, width - PAD * 2)
const x = (i: number) =>
points.length > 1 ? PAD + (i / (points.length - 1)) * plot : PAD + plot / 2
// A series that never moved sits on the centre line rather than on the floor,
// where a flat line reads as "zero" instead of "unchanged".
const y = (v: number) =>
span === 0 ? HEIGHT / 2 : HEIGHT - PAD - ((v - low) / span) * (HEIGHT - PAD * 2)
// Each run of known values is its own path, so a gap stays a gap.
const runs: string[] = []
let run: string[] = []
points.forEach((p, i) => {
if (p.value === null) {
if (run.length > 1) runs.push(run.join(' '))
run = []
return
}
run.push(`${run.length === 0 ? 'M' : 'L'}${x(i).toFixed(1)} ${y(p.value).toFixed(1)}`)
})
if (run.length > 1) runs.push(run.join(' '))
const lastKnown = points.reduce((found, p, i) => (p.value === null ? found : i), -1)
const marker = active !== null && points[active]?.value != null ? active : lastKnown
const onPointer = (event: React.PointerEvent<SVGSVGElement>) => {
const rect = event.currentTarget.getBoundingClientRect()
if (rect.width === 0) return
// Inverts the same inset the plot uses, so the crosshair lands on the point
// under the pointer rather than one over near the ends.
const fraction = (event.clientX - rect.left - PAD) / Math.max(1, rect.width - PAD * 2)
const index = Math.round(fraction * (points.length - 1))
probe(Math.min(points.length - 1, Math.max(0, index)))
}
const onKey = (event: React.KeyboardEvent<SVGSVGElement>) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
event.preventDefault()
const from = active ?? points.length - 1
probe(Math.min(points.length - 1, Math.max(0, from + (event.key === 'ArrowLeft' ? -1 : 1))))
}
// One point is a dot, not a trend, and no points is nothing at all. Both
// still occupy the slot, so the tiles in a row keep their baselines aligned —
// and the slot keeps the ref, so a later refetch that does have a series is
// measured rather than stuck on the fallback width.
return (
<div className="spark" ref={box}>
{known.length >= 2 && (
<svg
className="spark-svg"
width={width}
height={HEIGHT}
viewBox={`0 0 ${width} ${HEIGHT}`}
role="img"
tabIndex={0}
// The shape is decorative; the numbers are not. Screen readers get the
// span in words, and arrow keys walk the same readout a pointer gets.
aria-label={`${label}: ${known.length} points, ${known[0]?.label ?? ''} to ${
known[known.length - 1]?.label ?? ''
}`}
onPointerMove={onPointer}
onPointerLeave={() => probe(null)}
onFocus={() => probe(points.length - 1)}
onBlur={() => probe(null)}
onKeyDown={onKey}
>
{active !== null && (
<line className="spark-crosshair" x1={x(active)} y1={0} x2={x(active)} y2={HEIGHT} />
)}
{runs.map((d) => (
<path key={d.slice(0, 24)} className="spark-line" d={d} />
))}
{marker >= 0 && points[marker]?.value != null && (
<circle
className="spark-marker"
cx={x(marker)}
cy={y(points[marker].value)}
r={active === marker ? 3.5 : 2.5}
/>
)}
</svg>
)}
</div>
)
}