/** * One miner's share of the network over time. * * A single-series area chart: the job is change-over-time for one quantity, so * there is one hue and no legend — the panel title already says what is * plotted, and a legend box with one swatch would restate it. * * Inline SVG rather than a charting library. The whole chart is one path, one * fill and two axes; a library would be a hundred kilobytes and a second set of * theming rules to keep in step with the CSS custom properties above it. * * The viewBox tracks the container's measured width rather than being fixed and * scaled to fit. A fixed viewBox stretched to a full-width panel scales its * *height* with it — a 190-unit chart became 300px tall on a wide screen, and * the area fill went from a wash to a slab. Measuring keeps one SVG unit at one * CSS pixel, which also makes the crosshair land exactly under the pointer. */ import { useEffect, useMemo, useRef, useState } from 'react' import type { MinerSeriesPoint } from '../api/generated/MinerSeriesPoint' import { hashrate, share } from '../lib/format' const HEIGHT = 200 const PAD = { top: 16, right: 18, bottom: 26, left: 46 } /** Used until the container has been measured, and if ResizeObserver is absent. */ const FALLBACK_WIDTH = 720 interface Placed { point: MinerSeriesPoint x: number y: number } function useMeasuredWidth(): [React.RefObject, number] { const ref = useRef(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(320, entry.contentRect.width)) }) observer.observe(node) return () => observer.disconnect() }, []) return [ref, width] } function timeLabel(ms: number, spanMs: number): string { // A week of data wants dates; an hour wants clock time. Showing both at all // ranges is noise, and showing neither leaves a time series with no time. return new Date(ms).toLocaleString(undefined, { ...(spanMs > 86_400_000 ? { month: 'short', day: 'numeric' } : {}), hour: '2-digit', minute: '2-digit', }) } export function ShareChart({ points }: { points: MinerSeriesPoint[] }) { const [wrap, width] = useMeasuredWidth() const [hover, setHover] = useState(null) const chart = useMemo(() => { if (points.length === 0) return null const times = points.map((p) => new Date(p.at).getTime()) const t0 = Math.min(...times) const t1 = Math.max(...times) const span = t1 - t0 || 1 // The y-axis starts at zero — a share chart whose baseline floats makes a // steady miner look volatile. Headroom above the peak keeps the line off // the top edge. const peak = Math.max(...points.map((p) => p.share), 0.0001) const top = peak * 1.15 const plotW = width - PAD.left - PAD.right const plotH = HEIGHT - PAD.top - PAD.bottom const baseline = PAD.top + plotH const x = (t: number) => PAD.left + ((t - t0) / span) * plotW const y = (v: number) => baseline - (v / top) * plotH const placed: Placed[] = points.map((point, i) => ({ point, x: x(times[i] ?? t0), y: y(point.share), })) const line = placed .map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)} ${p.y.toFixed(1)}`) .join(' ') const first = placed[0] const last = placed[placed.length - 1] const area = first && last ? `${line} L${last.x.toFixed(1)} ${baseline} L${first.x.toFixed(1)} ${baseline} Z` : '' const yTicks = [0, top / 2, top].map((v) => ({ v, y: y(v) })) // Three x labels — ends and middle. More would collide on a narrow panel, // and the tooltip carries the exact time for any point. const xTicks = [0, 0.5, 1].map((f) => ({ t: t0 + span * f, x: x(t0 + span * f) })) return { placed, area, line, yTicks, xTicks, baseline, span, last } }, [points, width]) if (!chart) { return

No history recorded for this miner yet.

} const onMove = (event: React.PointerEvent) => { const rect = event.currentTarget.getBoundingClientRect() const local = event.clientX - rect.left let nearest: Placed | null = null let best = Infinity for (const p of chart.placed) { const d = Math.abs(p.x - local) if (d < best) { best = d nearest = p } } setHover(nearest) } return (
setHover(null)} > {chart.yTicks.map((t) => ( {share(t.v)} ))} {chart.xTicks.map((t, i) => ( {timeLabel(t.t, chart.span)} ))} {hover && ( <> {/* 2px surface ring so the marker stays legible where it sits on the line; the ring is part of the hit target, not just spacing. */} )} {/* One direct label, on the endpoint — the current value is the one a reader is looking for. Labelling every point would be unreadable. */} {!hover && chart.last && ( {share(chart.last.point.share)} )} {hover && (
width / 2 ? 'translate(-100%, 0)' : 'translate(10px, 0)', }} >
{timeLabel(new Date(hover.point.at).getTime(), chart.span)}
{share(hover.point.share)} of blocks
{hover.point.blocks} block{hover.point.blocks === 1 ? '' : 's'} ·{' '} {hashrate(hover.point.hashrate_estimate)}
)}
) }