|
| 1 | +import React, { useId, useState } from 'react'; |
| 2 | +import { Empty } from 'antd'; |
| 3 | + |
| 4 | +export interface AreaPoint { |
| 5 | + date: string; |
| 6 | + value: number; |
| 7 | +} |
| 8 | + |
| 9 | +export interface AreaChartProps { |
| 10 | + data: AreaPoint[]; |
| 11 | + height?: number; |
| 12 | + /** Line/fill color (defaults to the theme accent). */ |
| 13 | + color?: string; |
| 14 | + /** Prefix for the tooltip value, e.g. "$". */ |
| 15 | + valuePrefix?: string; |
| 16 | + /** Decimal places for the tooltip value. */ |
| 17 | + precision?: number; |
| 18 | +} |
| 19 | + |
| 20 | +/** Build a smooth (Catmull-Rom → cubic bezier) path through the points. */ |
| 21 | +function smoothPath(pts: { x: number; y: number }[]): string { |
| 22 | + if (pts.length === 0) return ''; |
| 23 | + if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`; |
| 24 | + let d = `M ${pts[0].x} ${pts[0].y}`; |
| 25 | + for (let i = 0; i < pts.length - 1; i++) { |
| 26 | + const p0 = pts[i - 1] || pts[i]; |
| 27 | + const p1 = pts[i]; |
| 28 | + const p2 = pts[i + 1]; |
| 29 | + const p3 = pts[i + 2] || p2; |
| 30 | + const cp1x = p1.x + (p2.x - p0.x) / 6; |
| 31 | + const cp1y = p1.y + (p2.y - p0.y) / 6; |
| 32 | + const cp2x = p2.x - (p3.x - p1.x) / 6; |
| 33 | + const cp2y = p2.y - (p3.y - p1.y) / 6; |
| 34 | + d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`; |
| 35 | + } |
| 36 | + return d; |
| 37 | +} |
| 38 | + |
| 39 | +/** |
| 40 | + * Responsive SVG area/line chart with a gradient fill, grid lines and a |
| 41 | + * hover tooltip. Pure SVG/CSS — no charting dependency. The viewBox uses a |
| 42 | + * fixed coordinate space and `non-scaling-stroke` so the line stays crisp at |
| 43 | + * any width; the hover cursor/dot/tooltip are HTML overlays. |
| 44 | + */ |
| 45 | +const AreaChart: React.FC<AreaChartProps> = ({ |
| 46 | + data, |
| 47 | + height = 200, |
| 48 | + color = 'var(--accent)', |
| 49 | + valuePrefix = '', |
| 50 | + precision = 2, |
| 51 | +}) => { |
| 52 | + const gradientId = useId().replace(/:/g, ''); |
| 53 | + const [hover, setHover] = useState<number | null>(null); |
| 54 | + |
| 55 | + if (!data || data.length === 0) { |
| 56 | + return <Empty description="暂无数据" image={Empty.PRESENTED_IMAGE_SIMPLE} />; |
| 57 | + } |
| 58 | + |
| 59 | + const W = 600; |
| 60 | + const padX = 10; |
| 61 | + const padTop = 14; |
| 62 | + const padBottom = 26; |
| 63 | + const innerH = height - padTop - padBottom; |
| 64 | + const n = data.length; |
| 65 | + const maxV = Math.max(...data.map(d => d.value), 1); |
| 66 | + |
| 67 | + const xFor = (i: number) => |
| 68 | + n === 1 ? W / 2 : padX + (i / (n - 1)) * (W - 2 * padX); |
| 69 | + const yFor = (v: number) => padTop + innerH - (v / maxV) * innerH; |
| 70 | + |
| 71 | + const pts = data.map((d, i) => ({ x: xFor(i), y: yFor(d.value) })); |
| 72 | + const line = smoothPath(pts); |
| 73 | + const baseY = padTop + innerH; |
| 74 | + const area = `${line} L ${pts[n - 1].x} ${baseY} L ${pts[0].x} ${baseY} Z`; |
| 75 | + const gridLines = [0, 0.25, 0.5, 0.75, 1].map(f => padTop + innerH - f * innerH); |
| 76 | + |
| 77 | + const handleMove = (e: React.MouseEvent<HTMLDivElement>) => { |
| 78 | + const rect = e.currentTarget.getBoundingClientRect(); |
| 79 | + const frac = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)); |
| 80 | + setHover(Math.round(frac * (n - 1))); |
| 81 | + }; |
| 82 | + |
| 83 | + return ( |
| 84 | + <div |
| 85 | + className="area-chart" |
| 86 | + style={{ height }} |
| 87 | + onMouseMove={handleMove} |
| 88 | + onMouseLeave={() => setHover(null)} |
| 89 | + > |
| 90 | + <svg |
| 91 | + className="area-svg" |
| 92 | + width="100%" |
| 93 | + height={height} |
| 94 | + viewBox={`0 0 ${W} ${height}`} |
| 95 | + preserveAspectRatio="none" |
| 96 | + > |
| 97 | + <defs> |
| 98 | + <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1"> |
| 99 | + <stop offset="0%" stopColor={color} stopOpacity="0.35" /> |
| 100 | + <stop offset="100%" stopColor={color} stopOpacity="0.02" /> |
| 101 | + </linearGradient> |
| 102 | + </defs> |
| 103 | + {gridLines.map((y, i) => ( |
| 104 | + <line |
| 105 | + key={i} |
| 106 | + x1={padX} |
| 107 | + y1={y} |
| 108 | + x2={W - padX} |
| 109 | + y2={y} |
| 110 | + stroke="var(--border-light)" |
| 111 | + strokeWidth="1" |
| 112 | + vectorEffect="non-scaling-stroke" |
| 113 | + /> |
| 114 | + ))} |
| 115 | + <path d={area} fill={`url(#${gradientId})`} /> |
| 116 | + <path |
| 117 | + d={line} |
| 118 | + fill="none" |
| 119 | + stroke={color} |
| 120 | + strokeWidth="2.5" |
| 121 | + strokeLinejoin="round" |
| 122 | + strokeLinecap="round" |
| 123 | + vectorEffect="non-scaling-stroke" |
| 124 | + /> |
| 125 | + </svg> |
| 126 | + |
| 127 | + <div className="area-xlabels"> |
| 128 | + <span>{data[0].date}</span> |
| 129 | + {n > 1 && <span>{data[n - 1].date}</span>} |
| 130 | + </div> |
| 131 | + |
| 132 | + {hover !== null && ( |
| 133 | + <> |
| 134 | + <div |
| 135 | + className="area-cursor" |
| 136 | + style={{ left: `${(pts[hover].x / W) * 100}%`, height: baseY }} |
| 137 | + /> |
| 138 | + <div |
| 139 | + className="area-dot" |
| 140 | + style={{ |
| 141 | + left: `${(pts[hover].x / W) * 100}%`, |
| 142 | + top: pts[hover].y, |
| 143 | + background: color, |
| 144 | + }} |
| 145 | + /> |
| 146 | + <div |
| 147 | + className="area-tooltip" |
| 148 | + style={{ left: `${(pts[hover].x / W) * 100}%`, top: pts[hover].y }} |
| 149 | + > |
| 150 | + <div className="area-tt-date">{data[hover].date}</div> |
| 151 | + <div className="area-tt-val"> |
| 152 | + {valuePrefix} |
| 153 | + {data[hover].value.toFixed(precision)} |
| 154 | + </div> |
| 155 | + </div> |
| 156 | + </> |
| 157 | + )} |
| 158 | + </div> |
| 159 | + ); |
| 160 | +}; |
| 161 | + |
| 162 | +export default AreaChart; |
0 commit comments