258 lines
9.2 KiB
TypeScript
258 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* METRICS scoreboard.
|
|
* - The four headline metrics count up on view (Motion) and are now the focus:
|
|
* each cell is an interactive tile with a hover lift, an accent edge that
|
|
* wipes in, and a tiny per-metric sparkline that animates.
|
|
* - The previously-meaningless decorative bars are replaced with a MEANINGFUL,
|
|
* LABELLED growth trend: a 6-month "client revenue" area+line chart with real
|
|
* month ticks, a value axis, and interactive data points that enlarge + show
|
|
* a tooltip on hover/focus. It communicates the compounding-growth story
|
|
* instead of being filler.
|
|
* Reduced-motion: numbers rest at final value, chart renders fully drawn, no
|
|
* count-up, no draw animation.
|
|
*/
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { gsap } from "./gsap";
|
|
import CountUp from "./CountUp";
|
|
import { metrics } from "../content";
|
|
|
|
/* 6-month sample "client revenue generated" trend (index, $M). Compounding
|
|
curve that mirrors the "$40M+ generated / 3.8x ROAS" headline story. */
|
|
const TREND = [
|
|
{ m: "Jan", v: 2.1 },
|
|
{ m: "Feb", v: 3.4 },
|
|
{ m: "Mar", v: 5.0 },
|
|
{ m: "Apr", v: 7.8 },
|
|
{ m: "May", v: 11.2 },
|
|
{ m: "Jun", v: 16.4 },
|
|
];
|
|
|
|
// tiny per-metric sparkline shapes (each different, matching the metric's story)
|
|
const SPARKS: string[] = [
|
|
"0,18 20,16 40,12 60,11 80,6 100,2", // revenue — steady climb
|
|
"0,16 20,14 40,15 60,9 80,7 100,3", // ROAS — climbing with a dip
|
|
"0,19 20,17 40,12 60,10 80,5 100,1", // organic — strong ramp
|
|
"0,8 20,7 40,8 60,6 80,7 100,5", // retention — high + stable
|
|
];
|
|
|
|
const W = 560;
|
|
const H = 200;
|
|
const PAD = { l: 38, r: 16, t: 18, b: 30 };
|
|
const maxV = Math.max(...TREND.map((d) => d.v));
|
|
|
|
function pt(i: number, v: number) {
|
|
const x = PAD.l + (i / (TREND.length - 1)) * (W - PAD.l - PAD.r);
|
|
const y = PAD.t + (1 - v / maxV) * (H - PAD.t - PAD.b);
|
|
return { x, y };
|
|
}
|
|
|
|
export default function Scoreboard() {
|
|
const root = useRef<HTMLElement>(null);
|
|
const [hover, setHover] = useState<number | null>(null);
|
|
|
|
const pts = TREND.map((d, i) => pt(i, d.v));
|
|
const linePath = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" ");
|
|
const areaPath = `${linePath} L${pts[pts.length - 1].x},${H - PAD.b} L${pts[0].x},${H - PAD.b} Z`;
|
|
// y-axis gridlines at 0 / 50% / 100% of max
|
|
const yTicks = [0, 0.5, 1].map((f) => ({
|
|
f,
|
|
y: PAD.t + (1 - f) * (H - PAD.t - PAD.b),
|
|
label: `$${(maxV * f).toFixed(0)}M`,
|
|
}));
|
|
|
|
useEffect(() => {
|
|
const el = root.current;
|
|
if (!el) return;
|
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
if (reduce) return;
|
|
|
|
const ctx = gsap.context(() => {
|
|
const tl = gsap.timeline({
|
|
scrollTrigger: { trigger: ".score__chart", start: "top 82%", once: true },
|
|
});
|
|
// gridlines fade, the area wipes up, the line draws itself, points pop in,
|
|
// and each metric sparkline draws — a coherent "data resolving" moment.
|
|
tl.from(".score__grid line", { autoAlpha: 0, duration: 0.5, stagger: 0.08 })
|
|
.from(".score__area", { autoAlpha: 0, yPercent: 8, duration: 0.8, ease: "power2.out" }, "-=0.2")
|
|
.from(".score__line", { drawSVG: "0%", duration: 1.4, ease: "power2.inOut" }, "-=0.7")
|
|
.from(
|
|
".score__pt",
|
|
{ scale: 0, transformOrigin: "center", duration: 0.5, ease: "back.out(2.2)", stagger: 0.08 },
|
|
"-=0.6"
|
|
)
|
|
.from(
|
|
".score__mlabel",
|
|
{ autoAlpha: 0, y: 6, duration: 0.4, stagger: 0.06 },
|
|
"-=0.8"
|
|
);
|
|
|
|
gsap.from(".score__spark polyline", {
|
|
drawSVG: "0%",
|
|
duration: 1,
|
|
ease: "power2.out",
|
|
stagger: 0.12,
|
|
scrollTrigger: { trigger: ".score__grid-stats", start: "top 85%", once: true },
|
|
});
|
|
}, el);
|
|
|
|
return () => ctx.revert();
|
|
}, []);
|
|
|
|
return (
|
|
<section ref={root} data-invert className="score frame" aria-labelledby="score-h">
|
|
<div className="wrap">
|
|
<header className="sec-head sec-head--center">
|
|
<p className="kicker">
|
|
<span className="kicker__dot" />
|
|
The scoreboard — sample data
|
|
</p>
|
|
<h2 id="score-h" className="display sec-head__title">
|
|
Numbers we report on
|
|
</h2>
|
|
</header>
|
|
|
|
{/* four headline metrics — the focus; interactive tiles with sparklines */}
|
|
<dl className="score__grid score__grid-stats">
|
|
{metrics.map((m, i) => (
|
|
<div className="score__cell" key={m.label}>
|
|
<dt className="sr-only">{m.label}</dt>
|
|
<dd
|
|
className={`score__num display ${"accent" in m && m.accent ? "is-accent" : ""}`}
|
|
>
|
|
<CountUp
|
|
to={m.value}
|
|
prefix={"prefix" in m ? m.prefix : ""}
|
|
suffix={m.suffix}
|
|
decimals={"decimals" in m ? m.decimals : 0}
|
|
/>
|
|
</dd>
|
|
<p className="score__lab" aria-hidden="true">
|
|
{m.label}
|
|
</p>
|
|
<svg
|
|
className="score__spark"
|
|
viewBox="0 0 100 20"
|
|
preserveAspectRatio="none"
|
|
aria-hidden="true"
|
|
>
|
|
<polyline
|
|
points={SPARKS[i]}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
|
|
{/* meaningful labelled growth trend — replaces the decorative bars */}
|
|
<figure className="score__chart">
|
|
<figcaption className="score__chart-cap">
|
|
Client revenue generated — cumulative, sample 6-month engagement
|
|
</figcaption>
|
|
<svg
|
|
className="score__trend"
|
|
viewBox={`0 0 ${W} ${H}`}
|
|
role="img"
|
|
aria-label="Cumulative client revenue rising from $2.1M in January to $16.4M in June (sample data)."
|
|
>
|
|
<defs>
|
|
<linearGradient id="scoreLine" x1="0" y1="0" x2="1" y2="0">
|
|
<stop offset="0%" stopColor="#2563eb" />
|
|
<stop offset="55%" stopColor="#7c3aed" />
|
|
<stop offset="100%" stopColor="#059669" />
|
|
</linearGradient>
|
|
<linearGradient id="scoreArea" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.28" />
|
|
<stop offset="100%" stopColor="#7c3aed" stopOpacity="0" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
{/* y gridlines + value labels */}
|
|
<g className="score__grid">
|
|
{yTicks.map((t) => (
|
|
<line key={t.f} x1={PAD.l} y1={t.y} x2={W - PAD.r} y2={t.y} />
|
|
))}
|
|
</g>
|
|
<g className="score__ylabels" aria-hidden="true">
|
|
{yTicks.map((t) => (
|
|
<text key={t.f} x={PAD.l - 8} y={t.y + 4} textAnchor="end">
|
|
{t.label}
|
|
</text>
|
|
))}
|
|
</g>
|
|
|
|
<path className="score__area" d={areaPath} fill="url(#scoreArea)" />
|
|
<path
|
|
className="score__line"
|
|
d={linePath}
|
|
fill="none"
|
|
stroke="url(#scoreLine)"
|
|
strokeWidth="3"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
|
|
{/* interactive data points + month labels */}
|
|
{TREND.map((d, i) => {
|
|
const p = pts[i];
|
|
const on = hover === i;
|
|
return (
|
|
<g
|
|
key={d.m}
|
|
className="score__ptg"
|
|
onMouseEnter={() => setHover(i)}
|
|
onMouseLeave={() => setHover(null)}
|
|
onFocus={() => setHover(i)}
|
|
onBlur={() => setHover(null)}
|
|
tabIndex={0}
|
|
role="img"
|
|
aria-label={`${d.m}: $${d.v}M`}
|
|
>
|
|
{/* invisible larger hit area */}
|
|
<rect
|
|
x={p.x - 22}
|
|
y={PAD.t}
|
|
width={44}
|
|
height={H - PAD.t - PAD.b}
|
|
fill="transparent"
|
|
/>
|
|
<circle
|
|
className="score__pt"
|
|
cx={p.x}
|
|
cy={p.y}
|
|
r={on ? 7 : 4.5}
|
|
/>
|
|
<text className="score__mlabel" x={p.x} y={H - 10} textAnchor="middle">
|
|
{d.m}
|
|
</text>
|
|
{on && (
|
|
<g className="score__tip">
|
|
<rect
|
|
x={p.x - 26}
|
|
y={p.y - 34}
|
|
width={52}
|
|
height={22}
|
|
rx={6}
|
|
/>
|
|
<text x={p.x} y={p.y - 19} textAnchor="middle">
|
|
${d.v}M
|
|
</text>
|
|
</g>
|
|
)}
|
|
</g>
|
|
);
|
|
})}
|
|
</svg>
|
|
</figure>
|
|
|
|
<p className="score__foot">Sample data — your numbers, reported monthly.</p>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|