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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
156 changes: 156 additions & 0 deletions src/components/chart-export-button.tsx
Original file line number Diff line number Diff line change
@@ -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 `<ChartWatermark*>` 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<HTMLElement | null>;
/** 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<Blob | null> => {
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 (
<span className={`inline-flex items-center rounded-md border border-ink/15 bg-paper shadow-sm ${className}`}>
<button
type="button"
onClick={onCopy}
disabled={state === "working"}
className="inline-flex items-center gap-1.5 rounded-l-md px-2.5 py-1 text-[11px] font-sans font-medium uppercase tracking-[0.1em] text-ink transition-colors hover:bg-paper-soft disabled:opacity-60"
title="Copy chart as PNG to clipboard"
aria-label="Copy chart as PNG"
>
{state === "working" ? (
<Loader2 size={11} strokeWidth={2} className="animate-spin" />
) : state === "copied" ? (
<Check size={11} strokeWidth={2.4} />
) : (
<Copy size={11} strokeWidth={2} />
)}
<span className="hidden sm:inline">
{state === "copied" ? "Copied" : state === "error" ? "Failed" : "Copy"}
</span>
</button>
<span aria-hidden className="h-4 w-px bg-ink/15" />
<button
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"
title="Download chart as PNG"
aria-label="Download chart as PNG"
>
<Download size={11} strokeWidth={2} />
</button>
</span>
);
}

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);
}
65 changes: 65 additions & 0 deletions src/components/chart-watermark.tsx
Original file line number Diff line number Diff line change
@@ -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:
* - `<ChartWatermarkSvg>` → inlined <text> element for SVG charts
* (time-series). Positioned bottom-right of the plot area.
* - `<ChartWatermarkHtml>` → 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 (
<text
x={x}
y={y}
textAnchor={anchor}
className="pointer-events-none select-none"
style={{
fontFamily: "var(--font-jetbrains-mono, ui-monospace, monospace)",
fontSize: "11px",
letterSpacing: "0.08em",
fill: "var(--color-ink)",
fillOpacity: 0.28,
}}
>
openchainbench.com
</text>
);
}

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 (
<span
aria-hidden
className={`pointer-events-none absolute ${cls} select-none font-sans font-medium uppercase text-[10px] tracking-[0.1em] text-ink`}
style={{ opacity: 0.28 }}
>
openchainbench.com
</span>
);
}
16 changes: 13 additions & 3 deletions src/components/ranked-bar-chart.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -42,6 +44,9 @@ export function RankedBarChart({
);

const [hoveredSlug, setHoveredSlug] = useState<string | null>(null);
// Wraps the whole figure so ChartExportButton can rasterise header +
// rows + watermark together.
const figureRef = useRef<HTMLElement | null>(null);

const colors = useMemo(
() => buildProviderColors(benchmark.results),
Expand Down Expand Up @@ -124,13 +129,17 @@ export function RankedBarChart({
}

return (
<figure className="my-2">
<figure className="relative my-2" ref={figureRef}>
<div className="mb-3 flex flex-wrap items-center justify-between gap-3 min-h-7">
<p className="inline-flex items-center gap-2 text-[10px] font-medium uppercase tracking-[0.18em] text-ink-muted">
<LiveDot />
<span>{benchmark.metric} · last 24 hours</span>
</p>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<ChartExportButton
targetRef={figureRef}
filename={`openchainbench-${benchmark.slug}-ranking`}
/>
{excludedCount > 0 && (
<button
type="button"
Expand Down Expand Up @@ -225,6 +234,7 @@ export function RankedBarChart({
<p className="mt-3 text-[11px] font-sans font-medium uppercase tracking-[0.12em] text-ink-faint">
{useLog ? "Log scale · " : ""}p50 · last 24 h · click rows to exclude
</p>
<ChartWatermarkHtml position="bottom-right" />
</figure>
);
}
6 changes: 6 additions & 0 deletions src/components/time-series-chart/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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. */}
<ChartWatermarkSvg x={padL + innerW - 2} y={padT + innerH - 6} anchor="end" />
</svg>

{/* Floating tooltip */}
Expand Down
14 changes: 11 additions & 3 deletions src/components/time-series-chart/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
"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";
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,
Expand Down Expand Up @@ -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 <figure> so ChartExportButton can rasterise the whole
// chart (header + SVG + watermark + legend) in one shot.
const figureRef = useRef<HTMLElement | null>(null);
const zoomScopeKey = `${range}|${region}`;
const [prevZoomScopeKey, setPrevZoomScopeKey] = useState(zoomScopeKey);
if (prevZoomScopeKey !== zoomScopeKey) {
Expand Down Expand Up @@ -435,15 +439,19 @@ export function TimeSeriesChart({
: RANGE_LABEL[range];

return (
<figure className="my-2">
<figure className="my-2" ref={figureRef}>
<div className="mb-3 flex items-center justify-between gap-3 min-h-7">
<p className="inline-flex items-center gap-2 text-[10px] font-medium uppercase tracking-[0.18em] text-ink-muted">
<LiveDot />
<span>
{metricLabelOverride ?? benchmark.metric} · {zoomLabel}
</span>
</p>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<ChartExportButton
targetRef={figureRef}
filename={`openchainbench-${benchmark.slug}-${range}`}
/>
{zoom && (
<button
type="button"
Expand Down
Loading