diff --git a/src/components/benchmark-body.tsx b/src/components/benchmark-body.tsx index 0798befe..5c32bac5 100644 --- a/src/components/benchmark-body.tsx +++ b/src/components/benchmark-body.tsx @@ -1,7 +1,7 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { Benchmark } from "@/types/benchmark"; import { liveResults } from "@/lib/provider-filters"; import { matchesChainSlug } from "@/lib/chain-aliases"; @@ -29,6 +29,7 @@ import { computeFieldStats } from "@/lib/stats"; import { defaultViewFor, viewsForBenchmark } from "@/lib/views"; import { useViewPreference } from "@/hooks/use-view-preference"; import type { ChainMeta } from "@/components/chain-tabs"; +import { FileText, Check } from "lucide-react"; type ChainOption = { value: string; label: string }; @@ -136,6 +137,50 @@ const REGION_DISPLAY: Record = { global: "Global", }; +function CsvButton({ benchmark }: { benchmark: Benchmark }) { + const [done, setDone] = useState(false); + + const onClick = useCallback(() => { + const series = benchmark.extras?.series24h; + if (!series || Object.keys(series).length === 0) return; + const slugs = benchmark.results.map((r) => r.slug); + const nPoints = 72; + const stepMs = (24 * 3_600_000) / nPoints; + const now = Date.now(); + const header = ["timestamp", ...slugs].join(","); + const rows = Array.from({ length: nPoints }, (_, i) => { + const ts = new Date(now - 24 * 3_600_000 + (i + 1) * stepMs).toISOString(); + return [ts, ...slugs.map((s) => { const v = series[s]?.[i]; return v == null ? "" : String(v); })].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 = `openchainbench-${benchmark.slug}-24h.csv`; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 4000); + setDone(true); + setTimeout(() => setDone(false), 1600); + }, [benchmark]); + + if (!benchmark.extras?.series24h || Object.keys(benchmark.extras.series24h).length === 0) return null; + + return ( + + ); +} + 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/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 && ( + <> + + + + )} ); }