Files
observer/web/src/components/ShareChart.tsx
T
grenadeandClaude Opus 5 337d1ef516
deploy / build (push) Successful in 6m44s
deploy / deploy-web (push) Successful in 8s
deploy / deploy-api (push) Successful in 15s
ci: format the frontend with prettier and gate it
`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
2026-09-08 15:01:28 +03:00

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>
)
}