cloudflare/vinext
Publicmirrored from https://github.com/cloudflare/vinextAvailable
examples/benchmarks/app/components/chart.tsx
226lines · modecode
| 1 | "use client"; |
| 2 | |
| 3 | import { useState, useRef } from "react"; |
| 4 | |
| 5 | // ─── Types ─────────────────────────────────────────────────────────────────── |
| 6 | |
| 7 | interface Series { |
| 8 | name: string; |
| 9 | color: string; |
| 10 | /** One value per label. null = no data for this commit. */ |
| 11 | values: (number | null)[]; |
| 12 | } |
| 13 | |
| 14 | interface TrendChartProps { |
| 15 | /** Shared x-axis labels (e.g. commit short hashes), same length as each series' values array. */ |
| 16 | labels: string[]; |
| 17 | series: Series[]; |
| 18 | yLabel?: string; |
| 19 | formatY?: (value: number) => string; |
| 20 | height?: number; |
| 21 | } |
| 22 | |
| 23 | // ─── SVG Trend Chart ───────────────────────────────────────────────────────── |
| 24 | |
| 25 | const PADDING = { top: 20, right: 20, bottom: 40, left: 70 }; |
| 26 | |
| 27 | export function TrendChart({ |
| 28 | labels, |
| 29 | series, |
| 30 | yLabel = "", |
| 31 | formatY = (v) => String(v), |
| 32 | height = 300, |
| 33 | }: TrendChartProps) { |
| 34 | const svgRef = useRef<SVGSVGElement>(null); |
| 35 | const [tooltip, setTooltip] = useState<{ |
| 36 | x: number; |
| 37 | y: number; |
| 38 | content: string; |
| 39 | } | null>(null); |
| 40 | |
| 41 | // Collect all non-null values to determine y-axis bounds |
| 42 | const allValues = series.flatMap((s) => |
| 43 | s.values.filter((v): v is number => v !== null), |
| 44 | ); |
| 45 | if (allValues.length === 0) { |
| 46 | return ( |
| 47 | <div className="flex items-center justify-center py-12 text-gray-400 text-sm"> |
| 48 | No data points to display |
| 49 | </div> |
| 50 | ); |
| 51 | } |
| 52 | |
| 53 | const numPoints = labels.length; |
| 54 | const minVal = Math.min(...allValues) * 0.9; |
| 55 | const maxVal = Math.max(...allValues) * 1.1; |
| 56 | |
| 57 | const chartWidth = 700; |
| 58 | const innerW = chartWidth - PADDING.left - PADDING.right; |
| 59 | const innerH = height - PADDING.top - PADDING.bottom; |
| 60 | |
| 61 | function scaleX(i: number): number { |
| 62 | if (numPoints <= 1) return PADDING.left + innerW / 2; |
| 63 | return PADDING.left + (i / (numPoints - 1)) * innerW; |
| 64 | } |
| 65 | |
| 66 | function scaleY(v: number): number { |
| 67 | const range = maxVal - minVal || 1; |
| 68 | return PADDING.top + innerH - ((v - minVal) / range) * innerH; |
| 69 | } |
| 70 | |
| 71 | // Y-axis ticks (5 ticks) |
| 72 | const yTicks = Array.from({ length: 5 }, (_, i) => { |
| 73 | const v = minVal + ((maxVal - minVal) * i) / 4; |
| 74 | return { value: v, y: scaleY(v) }; |
| 75 | }); |
| 76 | |
| 77 | // X-axis labels (show every Nth) |
| 78 | const labelStep = Math.max(1, Math.floor(numPoints / 8)); |
| 79 | |
| 80 | return ( |
| 81 | <div className="relative"> |
| 82 | <svg |
| 83 | ref={svgRef} |
| 84 | viewBox={`0 0 ${chartWidth} ${height}`} |
| 85 | className="w-full" |
| 86 | onMouseLeave={() => setTooltip(null)} |
| 87 | > |
| 88 | {/* Grid lines */} |
| 89 | {yTicks.map((tick, i) => ( |
| 90 | <g key={i}> |
| 91 | <line |
| 92 | x1={PADDING.left} |
| 93 | y1={tick.y} |
| 94 | x2={chartWidth - PADDING.right} |
| 95 | y2={tick.y} |
| 96 | stroke="#e5e7eb" |
| 97 | strokeDasharray="4 4" |
| 98 | /> |
| 99 | <text |
| 100 | x={PADDING.left - 8} |
| 101 | y={tick.y + 4} |
| 102 | textAnchor="end" |
| 103 | fontSize="11" |
| 104 | fill="#9ca3af" |
| 105 | > |
| 106 | {formatY(tick.value)} |
| 107 | </text> |
| 108 | </g> |
| 109 | ))} |
| 110 | |
| 111 | {/* X-axis labels */} |
| 112 | {labels.map((label, i) => { |
| 113 | if (i % labelStep !== 0 && i !== numPoints - 1) return null; |
| 114 | return ( |
| 115 | <text |
| 116 | key={i} |
| 117 | x={scaleX(i)} |
| 118 | y={height - 8} |
| 119 | textAnchor="middle" |
| 120 | fontSize="10" |
| 121 | fill="#9ca3af" |
| 122 | > |
| 123 | {label} |
| 124 | </text> |
| 125 | ); |
| 126 | })} |
| 127 | |
| 128 | {/* Series lines + dots */} |
| 129 | {series.map((s) => { |
| 130 | // Build path segments, breaking on null values |
| 131 | const segments: string[] = []; |
| 132 | let inSegment = false; |
| 133 | |
| 134 | for (let i = 0; i < s.values.length; i++) { |
| 135 | const v = s.values[i]; |
| 136 | if (v === null) { |
| 137 | inSegment = false; |
| 138 | continue; |
| 139 | } |
| 140 | const x = scaleX(i); |
| 141 | const y = scaleY(v); |
| 142 | if (!inSegment) { |
| 143 | segments.push(`M ${x} ${y}`); |
| 144 | inSegment = true; |
| 145 | } else { |
| 146 | segments.push(`L ${x} ${y}`); |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | if (segments.length === 0) return null; |
| 151 | const pathD = segments.join(" "); |
| 152 | |
| 153 | return ( |
| 154 | <g key={s.name}> |
| 155 | {/* Line */} |
| 156 | <path d={pathD} fill="none" stroke={s.color} strokeWidth="2" /> |
| 157 | {/* Dots — only for non-null values */} |
| 158 | {s.values.map((v, i) => { |
| 159 | if (v === null) return null; |
| 160 | return ( |
| 161 | <circle |
| 162 | key={i} |
| 163 | cx={scaleX(i)} |
| 164 | cy={scaleY(v)} |
| 165 | r="3.5" |
| 166 | fill={s.color} |
| 167 | stroke="white" |
| 168 | strokeWidth="1.5" |
| 169 | className="cursor-pointer" |
| 170 | onMouseEnter={(e) => { |
| 171 | const rect = svgRef.current?.getBoundingClientRect(); |
| 172 | if (!rect) return; |
| 173 | setTooltip({ |
| 174 | x: e.clientX - rect.left, |
| 175 | y: e.clientY - rect.top - 10, |
| 176 | content: `${s.name}: ${formatY(v)} (${labels[i]})`, |
| 177 | }); |
| 178 | }} |
| 179 | onMouseLeave={() => setTooltip(null)} |
| 180 | /> |
| 181 | ); |
| 182 | })} |
| 183 | </g> |
| 184 | ); |
| 185 | })} |
| 186 | |
| 187 | {/* Y-axis label */} |
| 188 | {yLabel && ( |
| 189 | <text |
| 190 | x={14} |
| 191 | y={height / 2} |
| 192 | textAnchor="middle" |
| 193 | transform={`rotate(-90, 14, ${height / 2})`} |
| 194 | fontSize="11" |
| 195 | fill="#6b7280" |
| 196 | > |
| 197 | {yLabel} |
| 198 | </text> |
| 199 | )} |
| 200 | </svg> |
| 201 | |
| 202 | {/* Legend */} |
| 203 | <div className="mt-3 flex justify-center gap-6 text-xs text-gray-500"> |
| 204 | {series.map((s) => ( |
| 205 | <div key={s.name} className="flex items-center gap-1.5"> |
| 206 | <span |
| 207 | className="inline-block h-2.5 w-2.5 rounded-full" |
| 208 | style={{ backgroundColor: s.color }} |
| 209 | /> |
| 210 | {s.name} |
| 211 | </div> |
| 212 | ))} |
| 213 | </div> |
| 214 | |
| 215 | {/* Tooltip */} |
| 216 | {tooltip && ( |
| 217 | <div |
| 218 | className="pointer-events-none absolute z-10 rounded bg-gray-900 px-2 py-1 text-xs text-white shadow-lg" |
| 219 | style={{ left: tooltip.x, top: tooltip.y, transform: "translate(-50%, -100%)" }} |
| 220 | > |
| 221 | {tooltip.content} |
| 222 | </div> |
| 223 | )} |
| 224 | </div> |
| 225 | ); |
| 226 | } |
| 227 | |