diff --git a/package.json b/package.json index 776f5474..89a36163 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@vercel/analytics": "^2.0.1", "cmdk": "^1.1.1", "fuse.js": "^7.4.2", + "html-to-image": "^1.11.13", "ioredis": "^5.11.1", "js-yaml": "^4.1.1", "lucide-react": "^1.11.0", diff --git a/src/components/chart-export-button.tsx b/src/components/chart-export-button.tsx new file mode 100644 index 00000000..94ba67a3 --- /dev/null +++ b/src/components/chart-export-button.tsx @@ -0,0 +1,156 @@ +"use client"; + +/** + * One-click PNG export for any chart wrapped by a ref'd container. + * + * UX shape: single split button — click to copy, tiny caret to open the + * download variant. Keeps the chart header uncluttered while giving + * both flows one gesture. Icon-only by default (with tooltips) so the + * button doesn't take label real estate; expands to text on hover for + * discoverability. + * + * Rasterisation goes through `html-to-image` so both SVG (time-series) + * and HTML-composed (ranked bar) charts capture correctly, including + * the `` attribution inside the frame. + * + * Clipboard path uses `navigator.clipboard.write(ClipboardItem)` which + * is Chromium + Safari; Firefox falls through to the download flow with + * no user-visible failure. Server + non-secure contexts also fall back. + */ + +import { useCallback, useState } from "react"; +import { Copy, Download, Check, Loader2 } from "lucide-react"; +import { toPng } from "html-to-image"; + +type Props = { + /** Ref to the element to capture. Should include chart + watermark. */ + targetRef: import("react").RefObject; + /** Filename prefix (no extension). Slug or bench name typically. */ + filename?: string; + /** Optional class to tweak wrapper size. */ + className?: string; +}; + +const PIXEL_RATIO = 2; // Retina-quality PNG for legible screenshots. + +export function ChartExportButton({ + targetRef, + filename = "openchainbench-chart", + className = "", +}: Props) { + const [state, setState] = useState<"idle" | "working" | "copied" | "error">("idle"); + + const capture = useCallback(async (): Promise => { + const el = targetRef.current; + if (!el) return null; + // Force a solid background — html-to-image renders transparency by + // default which produces unreadable screenshots on dark UIs when the + // user pastes into a light chat / doc. + const bg = + getComputedStyle(document.documentElement) + .getPropertyValue("--color-paper") + .trim() || "#0b0b0d"; + const dataUrl = await toPng(el, { + pixelRatio: PIXEL_RATIO, + backgroundColor: bg, + cacheBust: true, + // Skip external images with tainted crossOrigin (provider logos are + // same-origin from /logos/* so they render fine; anything else that + // fails to load is silently dropped rather than aborting the whole + // export). + skipFonts: false, + style: { boxShadow: "none" }, + }); + const res = await fetch(dataUrl); + return await res.blob(); + }, [targetRef]); + + const onCopy = useCallback(async () => { + setState("working"); + try { + const blob = await capture(); + if (!blob) throw new Error("no target"); + if ( + typeof navigator !== "undefined" && + typeof ClipboardItem !== "undefined" && + navigator.clipboard && + typeof navigator.clipboard.write === "function" + ) { + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); + setState("copied"); + setTimeout(() => setState("idle"), 1600); + return; + } + // Fallback: trigger download when clipboard write is unavailable + // (Firefox, insecure contexts, older Safari). + triggerDownload(blob, filename); + setState("copied"); + setTimeout(() => setState("idle"), 1600); + } catch (err) { + console.warn("[chart-export] copy failed", err); + setState("error"); + setTimeout(() => setState("idle"), 2200); + } + }, [capture, filename]); + + const onDownload = useCallback(async () => { + setState("working"); + try { + const blob = await capture(); + if (!blob) throw new Error("no target"); + triggerDownload(blob, filename); + setState("copied"); + setTimeout(() => setState("idle"), 1200); + } catch (err) { + console.warn("[chart-export] download failed", err); + setState("error"); + setTimeout(() => setState("idle"), 2200); + } + }, [capture, filename]); + + return ( + + + + + + ); +} + +function triggerDownload(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${filename}.png`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} diff --git a/src/components/chart-watermark.tsx b/src/components/chart-watermark.tsx new file mode 100644 index 00000000..fcacb29e --- /dev/null +++ b/src/components/chart-watermark.tsx @@ -0,0 +1,65 @@ +/** + * Subtle openchainbench.com attribution that renders in both the on-screen + * chart and inside any PNG capture (see `chart-export-button.tsx`). Kept + * low-opacity so it never competes with the data; the copy is small enough + * to survive Twitter/Slack recompression without pixel loss on the value. + * + * Two variants: + * - `` → inlined element for SVG charts + * (time-series). Positioned bottom-right of the plot area. + * - `` → absolutely-positioned HTML span for + * HTML-composed charts (ranked bar). Parent must be `relative`. + * + * Colour picks `--color-ink-faint` so it inherits the dark/light theme + * variables and stays legible on both without hardcoded hex. + */ + +export function ChartWatermarkSvg({ + x, + y, + anchor = "end", +}: { + x: number; + y: number; + anchor?: "start" | "middle" | "end"; +}) { + return ( + + openchainbench.com + + ); +} + +export function ChartWatermarkHtml({ + position = "bottom-right", +}: { + position?: "bottom-right" | "bottom-left" | "top-right"; +}) { + const cls = + position === "bottom-right" + ? "bottom-1 right-2" + : position === "bottom-left" + ? "bottom-1 left-2" + : "top-1 right-2"; + return ( + + openchainbench.com + + ); +} diff --git a/src/components/ranked-bar-chart.tsx b/src/components/ranked-bar-chart.tsx index 5caa826a..a06c4e6a 100644 --- a/src/components/ranked-bar-chart.tsx +++ b/src/components/ranked-bar-chart.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import type { Benchmark } from "@/types/benchmark"; import { fmtUnit } from "@/lib/format"; import { buildProviderColors } from "@/lib/series-colors"; @@ -9,6 +9,8 @@ import { useTopN } from "@/hooks/use-top-n"; import { LiveDot } from "@/components/live-dot"; import { ProviderLogo } from "@/components/provider-logo"; import { TopNSelector } from "@/components/top-n-selector"; +import { ChartExportButton } from "@/components/chart-export-button"; +import { ChartWatermarkHtml } from "@/components/chart-watermark"; type Props = { benchmark: Benchmark; @@ -42,6 +44,9 @@ export function RankedBarChart({ ); const [hoveredSlug, setHoveredSlug] = useState(null); + // Wraps the whole figure so ChartExportButton can rasterise header + + // rows + watermark together. + const figureRef = useRef(null); const colors = useMemo( () => buildProviderColors(benchmark.results), @@ -124,13 +129,17 @@ export function RankedBarChart({ } return ( -
+

{benchmark.metric} · last 24 hours

-
+
+ {excludedCount > 0 && (
); } diff --git a/src/components/time-series-chart/chart.tsx b/src/components/time-series-chart/chart.tsx index bdd76dfe..c48f600d 100644 --- a/src/components/time-series-chart/chart.tsx +++ b/src/components/time-series-chart/chart.tsx @@ -9,6 +9,7 @@ import { YAxis, XAxis } from "./axis"; import { SeriesGradients, SeriesPaths, HoverMarkers, type DrawnLine } from "./series"; import { Legend } from "./legend"; import { Tooltip } from "./tooltip"; +import { ChartWatermarkSvg } from "@/components/chart-watermark"; type ChartProps = { lines: LineWithColor[]; @@ -469,6 +470,11 @@ export function Chart({ stroke="var(--color-ink)" strokeWidth={1} /> + + {/* Attribution watermark. Sits inside the plot's right pad so it + doesn't overlap data lines; captured in any PNG export via + chart-export-button.tsx. Kept low-opacity by ChartWatermarkSvg. */} + {/* Floating tooltip */} diff --git a/src/components/time-series-chart/index.tsx b/src/components/time-series-chart/index.tsx index af3a66ee..0fb6f0ea 100644 --- a/src/components/time-series-chart/index.tsx +++ b/src/components/time-series-chart/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Globe } from "lucide-react"; import type { Benchmark } from "@/types/benchmark"; import { brandColor } from "@/lib/brand"; @@ -8,6 +8,7 @@ import { buildProviderColors } from "@/lib/series-colors"; import { useChartExclusion } from "@/hooks/use-chart-exclusion"; import { useTopN } from "@/hooks/use-top-n"; import { LiveDot } from "@/components/live-dot"; +import { ChartExportButton } from "@/components/chart-export-button"; import { TopNSelector } from "@/components/top-n-selector"; import { RANGES, @@ -134,6 +135,9 @@ export function TimeSeriesChart({ // Chart's internal re-renders. Reset to null when range or region // changes (the data shape is different, the old zoom doesn't apply). const [zoom, setZoom] = useState<{ startFrac: number; endFrac: number } | null>(null); + // Ref to the
so ChartExportButton can rasterise the whole + // chart (header + SVG + watermark + legend) in one shot. + const figureRef = useRef(null); const zoomScopeKey = `${range}|${region}`; const [prevZoomScopeKey, setPrevZoomScopeKey] = useState(zoomScopeKey); if (prevZoomScopeKey !== zoomScopeKey) { @@ -435,7 +439,7 @@ export function TimeSeriesChart({ : RANGE_LABEL[range]; return ( -
+

@@ -443,7 +447,11 @@ export function TimeSeriesChart({ {metricLabelOverride ?? benchmark.metric} · {zoomLabel}

-
+
+ {zoom && (