From 34c4f9f1b92d6add01ed5ce6bdab240666611d25 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 26 Jul 2026 01:35:29 +0200 Subject: [PATCH 1/2] feat: add CSV download button to chart toolbar --- src/components/chart-export-button.tsx | 63 +++++++++++++++++++++- src/components/time-series-chart/index.tsx | 3 ++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/components/chart-export-button.tsx b/src/components/chart-export-button.tsx index 47478bdc..5705f3c0 100644 --- a/src/components/chart-export-button.tsx +++ b/src/components/chart-export-button.tsx @@ -19,9 +19,11 @@ */ import { useCallback, useState } from "react"; -import { Copy, Download, Check, Loader2 } from "lucide-react"; +import { Copy, Download, Check, Loader2, FileText } from "lucide-react"; import { toBlob } from "html-to-image"; +type CsvLine = { slug: string; name: string; values: (number | null)[] }; + type Props = { /** Ref to the element to capture. Should include chart + watermark. */ targetRef: import("react").RefObject; @@ -29,6 +31,12 @@ type Props = { filename?: string; /** Optional class to tweak wrapper size. */ className?: string; + /** When provided, shows a CSV download button. */ + csvLines?: CsvLine[]; + /** Total hours the series spans (used to back-fill timestamps). */ + rangeHours?: number; + /** Expected number of points in each series. */ + rangePoints?: number; }; const PIXEL_RATIO = 2; // Retina-quality PNG for legible screenshots. @@ -37,10 +45,14 @@ export function ChartExportButton({ targetRef, filename = "openchainbench-chart", className = "", + csvLines, + rangeHours = 24, + rangePoints, }: Props) { const [state, setState] = useState< "idle" | "working" | "copied" | "downloaded" | "error" >("idle"); + const [csvState, setCsvState] = useState<"idle" | "done">("idle"); const capture = useCallback(async (): Promise => { const el = targetRef.current; @@ -121,6 +133,35 @@ export function ChartExportButton({ } }, [capture, filename]); + const onDownloadCsv = useCallback(() => { + if (!csvLines || csvLines.length === 0) return; + const now = Date.now(); + const rangeMs = rangeHours * 3_600_000; + const nPoints = rangePoints ?? csvLines[0]?.values.length ?? 72; + const stepMs = rangeMs / nPoints; + const header = ["timestamp", ...csvLines.map((l) => l.name)].join(","); + const rows = (csvLines[0]?.values ?? []).map((_, i) => { + const ts = new Date(now - rangeMs + (i + 1) * stepMs).toISOString(); + const vals = csvLines.map((l) => { + const v = l.values[i]; + return v == null ? "" : String(v); + }); + return [ts, ...vals].join(","); + }); + const blob = new Blob([[header, ...rows].join("\n")], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${filename}.csv`; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 4000); + setCsvState("done"); + setTimeout(() => setCsvState("idle"), 1600); + }, [csvLines, rangeHours, rangePoints, filename]); + return ( 0 ? "" : "rounded-r-md"}`} title="Download chart as PNG" aria-label="Download chart as PNG" > @@ -164,6 +205,24 @@ export function ChartExportButton({ )} + {csvLines && csvLines.length > 0 && ( + <> + + + + )} ); } diff --git a/src/components/time-series-chart/index.tsx b/src/components/time-series-chart/index.tsx index 0fb6f0ea..0a4d1781 100644 --- a/src/components/time-series-chart/index.tsx +++ b/src/components/time-series-chart/index.tsx @@ -451,6 +451,9 @@ export function TimeSeriesChart({ {zoom && ( + ); +} + export function BenchmarkBody({ variants, chainOptions, @@ -578,6 +623,14 @@ export function BenchmarkBody({ }; }, [viewBenchmark, activePanel]); + const sharedHeaderActions = ( + <> + + {pageActions} + + + ); + return ( <> {(hasLayerSplit || @@ -733,7 +786,7 @@ export function BenchmarkBody({ {view === "countLeaderboard" && ( {pageActions}} + headerActions={<>{sharedHeaderActions}} /> )} {view === "rankedBar" && ( @@ -758,7 +811,7 @@ export function BenchmarkBody({ onResetExcluded={resetExcluded} disableTopN={hasLayerSplit} topNControl={topNControl} - headerActions={<>{pageActions}} + headerActions={<>{sharedHeaderActions}} /> )} @@ -770,7 +823,7 @@ export function BenchmarkBody({ onResetExcluded={resetExcluded} disableTopN={hasLayerSplit} topNControl={topNControl} - headerActions={<>{pageActions}} + headerActions={<>{sharedHeaderActions}} /> )} {view === "donut" && ( @@ -780,7 +833,7 @@ export function BenchmarkBody({ onToggleExclude={toggleExclude} disableTopN={hasLayerSplit} topNControl={topNControl} - headerActions={<>{pageActions}} + headerActions={<>{sharedHeaderActions}} /> )} {view === "timeseries" && ( @@ -813,7 +866,7 @@ export function BenchmarkBody({ onResetExcluded={resetExcluded} disableTopN={hasLayerSplit} topNControl={topNControl} - headerActions={<>{pageActions}} + headerActions={<>{sharedHeaderActions}} seriesOverride={activePanel?.seriesByProvider} seriesOverride7d={activePanel?.seriesByProvider7d} seriesOverride30d={activePanel?.seriesByProvider30d} diff --git a/src/components/time-series-chart/index.tsx b/src/components/time-series-chart/index.tsx index 0a4d1781..0fb6f0ea 100644 --- a/src/components/time-series-chart/index.tsx +++ b/src/components/time-series-chart/index.tsx @@ -451,9 +451,6 @@ export function TimeSeriesChart({ {zoom && (