area-chart.tsx143 lines · main
1'use client';
2
3import { motion } from 'motion/react';
4import { useId } from 'react';
5
6/**
7 * Hand-rolled animated SVG area/line chart — no chart library. The line
8 * draws itself in on mount (motion pathLength), the gradient fill fades
9 * in behind it. Subsequent data updates (live polling) morph the path
10 * without replaying the entrance, so a 10s poll never flickers.
11 *
12 * Strokes use vectorEffect="non-scaling-stroke" so the stretched
13 * (preserveAspectRatio="none") viewBox never distorts line weight.
14 */
15
16export interface AreaChartPoint {
17 x: number;
18 y: number;
19}
20
21export interface AreaChartProps {
22 data: readonly AreaChartPoint[];
23 /** chart body height in px (labels add a little more). default 200. */
24 height?: number;
25 yFormat?: (y: number) => string;
26 xFormat?: (x: number) => string;
27 ariaLabel?: string;
28 /** shown centered while fewer than 2 points exist. */
29 pendingLabel?: string;
30}
31
32const VB_W = 600;
33const VB_H = 200;
34
35export function AreaChart({
36 data,
37 height = 200,
38 yFormat = (y) => y.toLocaleString('en-US', { maximumFractionDigits: 1 }),
39 xFormat = (x) => new Date(x).toLocaleTimeString(),
40 ariaLabel,
41 pendingLabel = 'collecting data — the chart fills in as live samples arrive.',
42}: AreaChartProps) {
43 const gradientId = useId();
44
45 if (data.length < 2) {
46 return (
47 <div
48 className="flex items-center justify-center rounded-xl border border-dashed border-[var(--color-border-subtle)] bg-[var(--color-surface)]"
49 style={{ height }}
50 >
51 <p className="font-mono text-xs text-[var(--color-text-subtle)]">{pendingLabel}</p>
52 </div>
53 );
54 }
55
56 const first = data[0]!;
57 const last = data[data.length - 1]!;
58 let yMin = Infinity;
59 let yMax = -Infinity;
60 for (const p of data) {
61 if (p.y < yMin) yMin = p.y;
62 if (p.y > yMax) yMax = p.y;
63 }
64 // Breathe a little so the line never kisses the frame edges.
65 const pad = (yMax - yMin || Math.abs(yMax) || 1) * 0.12;
66 const lo = yMin - pad;
67 const hi = yMax + pad;
68 const xSpan = last.x - first.x || 1;
69 const ySpan = hi - lo || 1;
70
71 const coords = data.map((p) => ({
72 x: ((p.x - first.x) / xSpan) * VB_W,
73 y: VB_H - ((p.y - lo) / ySpan) * VB_H,
74 }));
75 const lineD = coords
76 .map((c, i) => `${i === 0 ? 'M' : 'L'}${c.x.toFixed(2)},${c.y.toFixed(2)}`)
77 .join(' ');
78 const areaD = `${lineD} L${VB_W},${VB_H} L0,${VB_H} Z`;
79
80 return (
81 <div role="img" aria-label={ariaLabel} className="flex flex-col gap-2">
82 <div className="flex gap-3">
83 {/* y-axis labels — plain HTML so the stretched svg can't distort them */}
84 <div
85 className="flex w-14 shrink-0 flex-col justify-between py-0.5 text-right font-mono text-[10px] text-[var(--color-text-subtle)]"
86 style={{ height }}
87 >
88 <span>{yFormat(yMax)}</span>
89 <span>{yFormat((yMax + yMin) / 2)}</span>
90 <span>{yFormat(yMin)}</span>
91 </div>
92
93 <div className="relative min-w-0 flex-1" style={{ height }}>
94 {/* horizontal gridlines */}
95 <div aria-hidden className="pointer-events-none absolute inset-0 flex flex-col justify-between">
96 <div className="border-t border-[var(--color-border-subtle)]" />
97 <div className="border-t border-dashed border-[var(--color-border-subtle)]" />
98 <div className="border-t border-[var(--color-border-subtle)]" />
99 </div>
100
101 <svg
102 viewBox={`0 0 ${VB_W} ${VB_H}`}
103 preserveAspectRatio="none"
104 className="absolute inset-0 h-full w-full overflow-visible"
105 aria-hidden
106 >
107 <defs>
108 <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
109 <stop offset="0%" stopColor="var(--color-primary)" stopOpacity={0.28} />
110 <stop offset="100%" stopColor="var(--color-primary)" stopOpacity={0} />
111 </linearGradient>
112 </defs>
113 <motion.path
114 d={areaD}
115 fill={`url(#${gradientId})`}
116 initial={{ opacity: 0 }}
117 animate={{ opacity: 1 }}
118 transition={{ duration: 0.9, delay: 0.5, ease: 'easeOut' }}
119 />
120 <motion.path
121 d={lineD}
122 fill="none"
123 stroke="var(--color-primary)"
124 strokeWidth={2}
125 strokeLinecap="round"
126 strokeLinejoin="round"
127 vectorEffect="non-scaling-stroke"
128 initial={{ pathLength: 0 }}
129 animate={{ pathLength: 1 }}
130 transition={{ duration: 1.4, ease: [0.16, 1, 0.3, 1] }}
131 />
132 </svg>
133 </div>
134 </div>
135
136 {/* x-axis labels */}
137 <div className="flex justify-between pl-[68px] font-mono text-[10px] text-[var(--color-text-subtle)]">
138 <span>{xFormat(first.x)}</span>
139 <span>{xFormat(last.x)}</span>
140 </div>
141 </div>
142 );
143}