Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 59 additions & 6 deletions src/components/benchmark-body.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -29,6 +29,7 @@
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 };

Expand Down Expand Up @@ -136,6 +137,50 @@
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 (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-1.5 rounded-md border border-ink/15 bg-paper px-2 py-1 text-ink shadow-sm transition-colors hover:bg-paper-soft"
title="Download chart data as CSV (24 h)"
aria-label="Download CSV"
>
{done ? <Check size={11} strokeWidth={2.4} /> : <FileText size={11} strokeWidth={2} />}
</button>
);
}

export function BenchmarkBody({
variants,
chainOptions,
Expand Down Expand Up @@ -565,7 +610,7 @@
// not only on the timeseries view. Providers the panel has no value
// for (book could not fill the tier) drop out of the ranking, which
// is the skipped-not-extrapolated rule made visible.
const panelViewBenchmark = useMemo(() => {

Check failure on line 613 in src/components/benchmark-body.tsx

View workflow job for this annotation

GitHub Actions / check

React Hook "useMemo" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?
if (!activePanel) return viewBenchmark;
const vals = activePanel.values ?? {};
return {
Expand All @@ -578,6 +623,14 @@
};
}, [viewBenchmark, activePanel]);

const sharedHeaderActions = (
<>
<CsvButton benchmark={viewBenchmark ?? benchmark} />
{pageActions}
<ViewSwitcher allowed={allowedViews} value={view} onChange={setView} />
</>
);

return (
<>
{(hasLayerSplit ||
Expand Down Expand Up @@ -733,7 +786,7 @@
{view === "countLeaderboard" && (
<CountLeaderboard
benchmark={viewBenchmark}
headerActions={<>{pageActions}<ViewSwitcher allowed={allowedViews} value={view} onChange={setView} /></>}
headerActions={<>{sharedHeaderActions}</>}
/>
)}
{view === "rankedBar" && (
Expand All @@ -758,7 +811,7 @@
onResetExcluded={resetExcluded}
disableTopN={hasLayerSplit}
topNControl={topNControl}
headerActions={<>{pageActions}<ViewSwitcher allowed={allowedViews} value={view} onChange={setView} /></>}
headerActions={<>{sharedHeaderActions}</>}
/>
</>
)}
Expand All @@ -770,7 +823,7 @@
onResetExcluded={resetExcluded}
disableTopN={hasLayerSplit}
topNControl={topNControl}
headerActions={<>{pageActions}<ViewSwitcher allowed={allowedViews} value={view} onChange={setView} /></>}
headerActions={<>{sharedHeaderActions}</>}
/>
)}
{view === "donut" && (
Expand All @@ -780,7 +833,7 @@
onToggleExclude={toggleExclude}
disableTopN={hasLayerSplit}
topNControl={topNControl}
headerActions={<>{pageActions}<ViewSwitcher allowed={allowedViews} value={view} onChange={setView} /></>}
headerActions={<>{sharedHeaderActions}</>}
/>
)}
{view === "timeseries" && (
Expand Down Expand Up @@ -813,7 +866,7 @@
onResetExcluded={resetExcluded}
disableTopN={hasLayerSplit}
topNControl={topNControl}
headerActions={<>{pageActions}<ViewSwitcher allowed={allowedViews} value={view} onChange={setView} /></>}
headerActions={<>{sharedHeaderActions}</>}
seriesOverride={activePanel?.seriesByProvider}
seriesOverride7d={activePanel?.seriesByProvider7d}
seriesOverride30d={activePanel?.seriesByProvider30d}
Expand Down
63 changes: 61 additions & 2 deletions src/components/chart-export-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,24 @@
*/

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<HTMLElement | null>;
/** Filename prefix (no extension). Slug or bench name typically. */
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.
Expand All @@ -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<Blob | null> => {
const el = targetRef.current;
Expand Down Expand Up @@ -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 (
<span
data-chart-export-button="true"
Expand Down Expand Up @@ -154,7 +195,7 @@ export function ChartExportButton({
type="button"
onClick={onDownload}
disabled={state === "working"}
className="inline-flex items-center gap-1.5 rounded-r-md px-2 py-1 text-ink transition-colors hover:bg-paper-soft disabled:opacity-60"
className={`inline-flex items-center gap-1.5 px-2 py-1 text-ink transition-colors hover:bg-paper-soft disabled:opacity-60 ${csvLines && csvLines.length > 0 ? "" : "rounded-r-md"}`}
title="Download chart as PNG"
aria-label="Download chart as PNG"
>
Expand All @@ -164,6 +205,24 @@ export function ChartExportButton({
<Download size={11} strokeWidth={2} />
)}
</button>
{csvLines && csvLines.length > 0 && (
<>
<span aria-hidden className="h-4 w-px bg-ink/15" />
<button
type="button"
onClick={onDownloadCsv}
className="inline-flex items-center gap-1.5 rounded-r-md px-2 py-1 text-ink transition-colors hover:bg-paper-soft"
title="Download chart data as CSV"
aria-label="Download chart data as CSV"
>
{csvState === "done" ? (
<Check size={11} strokeWidth={2.4} />
) : (
<FileText size={11} strokeWidth={2} />
)}
</button>
</>
)}
</span>
);
}
Expand Down
Loading