import React, { useEffect, useMemo, useRef, useState } from 'react'
export const nf = new Intl.NumberFormat('en-US')
export const fmt = (n) => (n == null || isNaN(n) ? '—' : nf.format(Math.round(n)))
export const pct = (g) => (g == null || isNaN(g) ? '—' : (g > 0 ? '+' : '') + (g * 100).toFixed(0) + '%')
export function useCountUp(target, dur = 900) {
const [v, setV] = useState(0)
useEffect(() => {
let raf
const t0 = performance.now()
const tick = (t) => {
const p = Math.min(1, (t - t0) / dur)
setV(Math.round(target * (1 - Math.pow(1 - p, 3))))
if (p < 1) raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [target, dur])
return v
}
export function Stat({ k, v, d, dCls = '', sub }) {
const n = useCountUp(v)
return (
{k}
{fmt(n)}
{d != null &&
{d}
}
{sub &&
{sub}
}
)
}
export function Sparkline({ data, w = 90, h = 26, color = 'var(--accent)', area = true }) {
if (!data || data.length < 2) return null
const max = Math.max(1, ...data)
const min = Math.min(...data)
const span = Math.max(1e-9, max - min)
const pts = data.map((v, i) => [
(i / (data.length - 1)) * (w - 4) + 2,
h - 3 - ((v - min) / span) * (h - 8),
])
const line = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' ')
return (
)
}
// columnar series: {day:[], dl_all:[], dl30:[], likes:[]}
// bars = daily downloads (diff of dl_all), line = cumulative
export function BigChart({ series, height = 300 }) {
const wrapRef = useRef(null)
const [hover, setHover] = useState(null)
const { days, daily, cum } = useMemo(() => {
const out = { days: [], daily: [], cum: [] }
if (!series || !series.day || series.day.length < 2) return out
for (let i = 1; i < series.day.length; i++) {
out.days.push(series.day[i])
out.daily.push(Math.max(0, series.dl_all[i] - series.dl_all[i - 1]))
out.cum.push(series.dl_all[i])
}
return out
}, [series])
if (days.length < 2) {
return Not enough history for this model yet.
}
const W = 1000, H = height, pad = { l: 56, r: 14, t: 16, b: 26 }
const iw = W - pad.l - pad.r, ih = H - pad.t - pad.b
const dmax = Math.max(1, ...daily)
const cmax = Math.max(1, ...cum)
const x = (i) => pad.l + (i / (daily.length - 1)) * iw
const yd = (v) => pad.t + ih - (v / dmax) * ih
const yc = (v) => pad.t + ih - (v / cmax) * ih
const bw = Math.max(1, iw / daily.length - 1.2)
const bars = daily.map((v, i) => (
3 ? 1.5 : 0}
fill={hover === i ? 'var(--accent)' : 'var(--bar)'} opacity={hover == null || hover === i ? 1 : 0.85} />
))
const cpts = cum.map((v, i) => x(i).toFixed(1) + ',' + yc(v).toFixed(1)).join(' ')
const grid = [0, 1, 2, 3, 4].map((g) => {
const gy = pad.t + ih - (g / 4) * ih
return (
{fmt((dmax * g) / 4)}
)
})
const onMove = (e) => {
const rect = wrapRef.current.getBoundingClientRect()
const rel = (e.clientX - rect.left) / rect.width
setHover(Math.max(0, Math.min(daily.length - 1, Math.round(rel * (daily.length - 1)))))
}
return (
setHover(null)}>
{hover != null && (
{days[hover]}
{fmt(daily[hover])} downloads/day
{fmt(cum[hover])} all time
)}
)
}
// simple area/line chart from (days[], vals[]) — used for the hub-wide pulse
export function AreaChart({ days, vals, height = 240 }) {
const wrapRef = useRef(null)
const [hover, setHover] = useState(null)
if (!days || days.length < 2) return null
const W = 1000, H = height, pad = { l: 56, r: 14, t: 14, b: 26 }
const iw = W - pad.l - pad.r, ih = H - pad.t - pad.b
const max = Math.max(1, ...vals)
const x = (i) => pad.l + (i / (days.length - 1)) * iw
const y = (v) => pad.t + ih - (v / max) * ih
const pts = vals.map((v, i) => x(i).toFixed(1) + ',' + y(v).toFixed(1)).join(' ')
const grid = [0, 1, 2, 3, 4].map((g) => {
const gy = pad.t + ih - (g / 4) * ih
return (
{fmt((max * g) / 4)}
)
})
const onMove = (e) => {
const rect = wrapRef.current.getBoundingClientRect()
const rel = (e.clientX - rect.left) / rect.width
setHover(Math.max(0, Math.min(days.length - 1, Math.round(rel * (days.length - 1)))))
}
return (
setHover(null)}>
{hover != null && (
{days[hover]}
{fmt(vals[hover])} downloads
)}
)
}