`cargo fmt --check` has gated the Rust half since the first commit; nothing gated the TypeScript, so 25 of 31 files under web/src had drifted from the .prettierrc that was already sitting in the repo. `pnpm format:check` is now the counterpart, in the same step as `pnpm lint`. The reformat is whitespace only — the emitted bundle hashes identically before and after. web/.prettierignore excludes src/api/generated. Those files are written by ts-rs from the entities crate and CI already fails on any diff in them after `cargo test`; formatting them here would be reverted by the next regeneration and turn that drift gate red on every subsequent build. Verified by regenerating and confirming the tree stays clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
220 lines
7.6 KiB
TypeScript
220 lines
7.6 KiB
TypeScript
/**
|
|
* 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<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(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<Placed | null>(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 <p className="empty">No history recorded for this miner yet.</p>
|
|
}
|
|
|
|
const onMove = (event: React.PointerEvent<SVGSVGElement>) => {
|
|
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 (
|
|
<div ref={wrap} style={{ position: 'relative' }}>
|
|
<svg
|
|
className="chart"
|
|
width={width}
|
|
height={HEIGHT}
|
|
viewBox={`0 0 ${width} ${HEIGHT}`}
|
|
role="img"
|
|
aria-label={`Share of network blocks over time, from ${share(chart.placed[0]?.point.share ?? 0)} to ${share(chart.last?.point.share ?? 0)}`}
|
|
onPointerMove={onMove}
|
|
onPointerLeave={() => setHover(null)}
|
|
>
|
|
{chart.yTicks.map((t) => (
|
|
<g key={t.v}>
|
|
<line className="grid" x1={PAD.left} x2={width - PAD.right} y1={t.y} y2={t.y} />
|
|
<text className="chart-axis" x={PAD.left - 8} y={t.y + 3.5} textAnchor="end">
|
|
{share(t.v)}
|
|
</text>
|
|
</g>
|
|
))}
|
|
|
|
{chart.xTicks.map((t, i) => (
|
|
<text
|
|
key={t.x}
|
|
className="chart-axis"
|
|
x={t.x}
|
|
y={HEIGHT - 8}
|
|
textAnchor={i === 0 ? 'start' : i === 2 ? 'end' : 'middle'}
|
|
>
|
|
{timeLabel(t.t, chart.span)}
|
|
</text>
|
|
))}
|
|
|
|
<path className="area" d={chart.area} />
|
|
<path className="line" d={chart.line} />
|
|
|
|
{hover && (
|
|
<>
|
|
<line
|
|
className="crosshair"
|
|
x1={hover.x}
|
|
x2={hover.x}
|
|
y1={PAD.top}
|
|
y2={chart.baseline}
|
|
/>
|
|
{/* 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. */}
|
|
<circle className="marker" cx={hover.x} cy={hover.y} r={4.5} />
|
|
</>
|
|
)}
|
|
|
|
{/* 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 && (
|
|
<text
|
|
className="chart-axis"
|
|
x={chart.last.x - 7}
|
|
// Clamped inside the plot area. A miner whose latest share is zero
|
|
// puts the endpoint on the baseline, and an unclamped label lands on
|
|
// top of the time axis.
|
|
y={Math.min(Math.max(chart.last.y - 9, PAD.top + 10), chart.baseline - 8)}
|
|
textAnchor="end"
|
|
style={{ fill: 'var(--data-bright)', fontSize: 11 }}
|
|
>
|
|
{share(chart.last.point.share)}
|
|
</text>
|
|
)}
|
|
</svg>
|
|
|
|
{hover && (
|
|
<div
|
|
className="tooltip"
|
|
style={{
|
|
left: hover.x,
|
|
top: 4,
|
|
// Flip past the midpoint so the tooltip never runs off the right
|
|
// edge of the panel.
|
|
transform: hover.x > width / 2 ? 'translate(-100%, 0)' : 'translate(10px, 0)',
|
|
}}
|
|
>
|
|
<div className="tooltip-label">
|
|
{timeLabel(new Date(hover.point.at).getTime(), chart.span)}
|
|
</div>
|
|
<div>
|
|
<strong>{share(hover.point.share)}</strong> of blocks
|
|
</div>
|
|
<div style={{ color: 'var(--text-secondary)' }}>
|
|
{hover.point.blocks} block{hover.point.blocks === 1 ? '' : 's'} ·{' '}
|
|
{hashrate(hover.point.hashrate_estimate)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|