cloudflare/vinext

Public

mirrored from https://github.com/cloudflare/vinextAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.0.9

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

examples/benchmarks/app/components/chart.tsx

226lines · modecode

1"use client";
2
3import { useState, useRef } from "react";
4
5// ─── Types ───────────────────────────────────────────────────────────────────
6
7interface Series {
8 name: string;
9 color: string;
10 /** One value per label. null = no data for this commit. */
11 values: (number | null)[];
12}
13
14interface 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
25const PADDING = { top: 20, right: 20, bottom: 40, left: 70 };
26
27export 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