|
1 | | -import { motion } from 'framer-motion' |
2 | | -import type { CSSProperties } from 'react' |
3 | | -import { useInView } from '../deck/useInView' |
| 1 | +import { motion } from 'framer-motion'; |
| 2 | +import type { CSSProperties } from 'react'; |
| 3 | +import { useInView } from '../deck/useInView'; |
4 | 4 |
|
5 | 5 | /* A small hand-built chart kit — bar, line/area, and donut. Each draws itself in |
6 | 6 | when scrolled into view. All token-driven (one accent), no chart library. |
7 | 7 | <BarChart data={[{label:'Mon',value:44}, …]} /> |
8 | 8 | <LineChart points={[12,18,15,26,22,34,30]} /> |
9 | 9 | <DonutChart value={72} label="Adoption" /> */ |
10 | 10 |
|
11 | | -export function BarChart({ data, height = 200 }: { data: { label: string; value: number }[]; height?: number }) { |
12 | | - const { ref, inView } = useInView<HTMLDivElement>(0.3) |
13 | | - const max = Math.max(...data.map((d) => d.value)) || 1 |
| 11 | +export function BarChart({ |
| 12 | + data, |
| 13 | + height = 200, |
| 14 | +}: { |
| 15 | + data: { label: string; value: number }[]; |
| 16 | + height?: number; |
| 17 | +}) { |
| 18 | + const { ref, inView } = useInView<HTMLDivElement>(0.3); |
| 19 | + const max = Math.max(...data.map((d) => d.value)) || 1; |
14 | 20 | return ( |
15 | 21 | <div className="ch ch-bars" ref={ref} style={{ height }}> |
16 | 22 | {data.map((d, i) => ( |
17 | 23 | <div key={i} className="ch-col"> |
18 | 24 | <div className="ch-bar-track"> |
19 | | - <motion.span className="ch-bar" initial={{ height: 0 }} animate={{ height: inView ? `${(d.value / max) * 100}%` : 0 }} transition={{ duration: 0.7, delay: i * 0.06, ease: [0.16, 1, 0.3, 1] }} /> |
| 25 | + <motion.span |
| 26 | + className="ch-bar" |
| 27 | + initial={{ height: 0 }} |
| 28 | + animate={{ height: inView ? `${(d.value / max) * 100}%` : 0 }} |
| 29 | + transition={{ |
| 30 | + duration: 0.7, |
| 31 | + delay: i * 0.06, |
| 32 | + ease: [0.16, 1, 0.3, 1], |
| 33 | + }} |
| 34 | + /> |
20 | 35 | </div> |
21 | 36 | <div className="ch-x">{d.label}</div> |
22 | 37 | </div> |
23 | 38 | ))} |
24 | 39 | </div> |
25 | | - ) |
| 40 | + ); |
26 | 41 | } |
27 | 42 |
|
28 | | -export function LineChart({ points, height = 200 }: { points: number[]; height?: number }) { |
29 | | - const { ref, inView } = useInView<SVGSVGElement>(0.3) |
30 | | - const w = 300, h = 120 |
31 | | - const max = Math.max(...points), min = Math.min(...points) |
32 | | - const span = max - min || 1 |
33 | | - const coords = points.map((p, i) => [(i / (points.length - 1)) * w, h - ((p - min) / span) * (h - 10) - 5]) |
34 | | - const line = coords.map((c, i) => `${i === 0 ? 'M' : 'L'}${c[0].toFixed(1)},${c[1].toFixed(1)}`).join(' ') |
35 | | - const area = `${line} L${w},${h} L0,${h} Z` |
| 43 | +export function LineChart({ |
| 44 | + points, |
| 45 | + height = 200, |
| 46 | +}: { |
| 47 | + points: number[]; |
| 48 | + height?: number; |
| 49 | +}) { |
| 50 | + const { ref, inView } = useInView<SVGSVGElement>(0.3); |
| 51 | + const w = 300, |
| 52 | + h = 120; |
| 53 | + const max = Math.max(...points), |
| 54 | + min = Math.min(...points); |
| 55 | + const span = max - min || 1; |
| 56 | + const coords = points.map((p, i) => [ |
| 57 | + (i / (points.length - 1)) * w, |
| 58 | + h - ((p - min) / span) * (h - 10) - 5, |
| 59 | + ]); |
| 60 | + const line = coords |
| 61 | + .map( |
| 62 | + (c, i) => `${i === 0 ? 'M' : 'L'}${c[0].toFixed(1)},${c[1].toFixed(1)}` |
| 63 | + ) |
| 64 | + .join(' '); |
| 65 | + const area = `${line} L${w},${h} L0,${h} Z`; |
36 | 66 | return ( |
37 | | - <svg ref={ref} className="ch-line" viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ height }}> |
| 67 | + <svg |
| 68 | + ref={ref} |
| 69 | + className="ch-line" |
| 70 | + viewBox={`0 0 ${w} ${h}`} |
| 71 | + preserveAspectRatio="none" |
| 72 | + style={{ height }} |
| 73 | + > |
38 | 74 | <defs> |
39 | | - <linearGradient id="ch-fill" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor="var(--primary)" stopOpacity="0.34" /><stop offset="100%" stopColor="var(--primary)" stopOpacity="0" /></linearGradient> |
| 75 | + <linearGradient id="ch-fill" x1="0" y1="0" x2="0" y2="1"> |
| 76 | + <stop offset="0%" stopColor="var(--primary)" stopOpacity="0.34" /> |
| 77 | + <stop offset="100%" stopColor="var(--primary)" stopOpacity="0" /> |
| 78 | + </linearGradient> |
40 | 79 | </defs> |
41 | | - <motion.path d={area} fill="url(#ch-fill)" initial={{ opacity: 0 }} animate={{ opacity: inView ? 1 : 0 }} transition={{ duration: 0.6, delay: 0.5 }} /> |
42 | | - <motion.path d={line} fill="none" stroke="var(--primary)" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" initial={{ pathLength: 0 }} animate={{ pathLength: inView ? 1 : 0 }} transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1] }} /> |
| 80 | + <motion.path |
| 81 | + d={area} |
| 82 | + fill="url(#ch-fill)" |
| 83 | + initial={{ opacity: 0 }} |
| 84 | + animate={{ opacity: inView ? 1 : 0 }} |
| 85 | + transition={{ duration: 0.6, delay: 0.5 }} |
| 86 | + /> |
| 87 | + <motion.path |
| 88 | + d={line} |
| 89 | + fill="none" |
| 90 | + stroke="var(--primary)" |
| 91 | + strokeWidth={2.5} |
| 92 | + strokeLinecap="round" |
| 93 | + strokeLinejoin="round" |
| 94 | + initial={{ pathLength: 0 }} |
| 95 | + animate={{ pathLength: inView ? 1 : 0 }} |
| 96 | + transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1] }} |
| 97 | + /> |
43 | 98 | </svg> |
44 | | - ) |
| 99 | + ); |
45 | 100 | } |
46 | 101 |
|
47 | | -export function DonutChart({ value, label, size = 168 }: { value: number; label?: string; size?: number }) { |
48 | | - const { ref, inView } = useInView<SVGSVGElement>(0.4) |
49 | | - const r = 54 |
50 | | - const circ = 2 * Math.PI * r |
| 102 | +export function DonutChart({ |
| 103 | + value, |
| 104 | + label, |
| 105 | + size = 168, |
| 106 | +}: { |
| 107 | + value: number; |
| 108 | + label?: string; |
| 109 | + size?: number; |
| 110 | +}) { |
| 111 | + const { ref, inView } = useInView<SVGSVGElement>(0.4); |
| 112 | + const r = 54; |
| 113 | + const circ = 2 * Math.PI * r; |
51 | 114 | return ( |
52 | | - <div className="ch-donut" style={{ '--ch-size': `${size}px` } as CSSProperties}> |
| 115 | + <div |
| 116 | + className="ch-donut" |
| 117 | + style={{ '--ch-size': `${size}px` } as CSSProperties} |
| 118 | + > |
53 | 119 | <svg ref={ref} viewBox="0 0 140 140"> |
54 | | - <circle cx="70" cy="70" r={r} fill="none" stroke="var(--hair)" strokeWidth={12} /> |
| 120 | + <circle |
| 121 | + cx="70" |
| 122 | + cy="70" |
| 123 | + r={r} |
| 124 | + fill="none" |
| 125 | + stroke="var(--hair)" |
| 126 | + strokeWidth={12} |
| 127 | + /> |
55 | 128 | <motion.circle |
56 | | - cx="70" cy="70" r={r} fill="none" stroke="var(--primary)" strokeWidth={12} strokeLinecap="round" |
57 | | - strokeDasharray={circ} transform="rotate(-90 70 70)" |
58 | | - initial={{ strokeDashoffset: circ }} animate={{ strokeDashoffset: inView ? circ * (1 - value / 100) : circ }} transition={{ duration: 1.1, ease: [0.16, 1, 0.3, 1] }} |
| 129 | + cx="70" |
| 130 | + cy="70" |
| 131 | + r={r} |
| 132 | + fill="none" |
| 133 | + stroke="var(--primary)" |
| 134 | + strokeWidth={12} |
| 135 | + strokeLinecap="round" |
| 136 | + strokeDasharray={circ} |
| 137 | + transform="rotate(-90 70 70)" |
| 138 | + initial={{ strokeDashoffset: circ }} |
| 139 | + animate={{ |
| 140 | + strokeDashoffset: inView ? circ * (1 - value / 100) : circ, |
| 141 | + }} |
| 142 | + transition={{ duration: 1.1, ease: [0.16, 1, 0.3, 1] }} |
59 | 143 | /> |
60 | | - <text x="70" y="70" textAnchor="middle" dominantBaseline="central" className="ch-donut-val">{value}%</text> |
| 144 | + <text |
| 145 | + x="70" |
| 146 | + y="70" |
| 147 | + textAnchor="middle" |
| 148 | + dominantBaseline="central" |
| 149 | + className="ch-donut-val" |
| 150 | + > |
| 151 | + {value}% |
| 152 | + </text> |
61 | 153 | </svg> |
62 | 154 | {label && <div className="ch-donut-label">{label}</div>} |
63 | 155 | </div> |
64 | | - ) |
| 156 | + ); |
65 | 157 | } |
0 commit comments