diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx index 488a6aa3..d461b56a 100644 --- a/src/app/benchmarks/[slug]/page.tsx +++ b/src/app/benchmarks/[slug]/page.tsx @@ -208,7 +208,11 @@ export default async function BenchmarkPage({ {/* Share / export */} {!isDraft && ( - + )} {/* Source code link — bottom of page */} diff --git a/src/app/benchmarks/[slug]/share-card/route.tsx b/src/app/benchmarks/[slug]/share-card/route.tsx index 35d2b1af..a72b4b13 100644 --- a/src/app/benchmarks/[slug]/share-card/route.tsx +++ b/src/app/benchmarks/[slug]/share-card/route.tsx @@ -1,7 +1,10 @@ import { ImageResponse } from "next/og"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { getBenchmark } from "@/data/benchmarks"; import { buildProviderColors } from "@/lib/series-colors"; import { fmtUnit, fmtValue, unitSuffix } from "@/lib/format"; +import type { Benchmark } from "@/types/benchmark"; export const runtime = "nodejs"; @@ -15,6 +18,250 @@ const INK_FAINT = "#a59b87"; const RULE = "rgba(24, 22, 20, 0.12)"; const GOOD = "#3d6d3d"; +// ─── Cached assets ───────────────────────────────────────────────────── +let _logoDataUrl: string | null = null; +function getLogoDataUrl() { + if (!_logoDataUrl) { + const buf = readFileSync(join(process.cwd(), "public", "logo.png")); + _logoDataUrl = `data:image/png;base64,${buf.toString("base64")}`; + } + return _logoDataUrl; +} + +function formatTimestamp(iso: string): string { + const d = new Date(iso); + const date = d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + const time = d.toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + timeZone: "UTC", + }); + return `${date} · ${time} UTC`; +} + +// ─── Reusable chrome ─────────────────────────────────────────────────── +function Logo({ size = 36 }: { size?: number }) { + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + OpenChainBench +
+ OpenChainBench +
+
+ ); +} + +function LivePill() { + return ( +
+
+ LIVE · 24H +
+ ); +} + +function CategoryPill({ children }: { children: string }) { + return ( +
+ {children} +
+ ); +} + +function CardHeader({ + benchmark, + showCategory = true, +}: { + benchmark: Benchmark; + showCategory?: boolean; +}) { + return ( +
+ +
+ + {showCategory && {benchmark.category}} +
+ {formatTimestamp(benchmark.lastRunAt)} +
+
+
+ ); +} + +function CardFooter({ + benchmark, + rightText, +}: { + benchmark: Benchmark; + rightText?: string; +}) { + return ( +
+ + openchainbench.xyz · № {benchmark.number} · {benchmark.category} + + + {rightText ?? + `${benchmark.results.length} providers · ${Math.round( + benchmark.sampleSize + ).toLocaleString()} samples`} + +
+ ); +} + +/** Wraps any inner content with the consistent card chrome. */ +function CardShell({ + benchmark, + accentColor, + children, + rightText, + showCategory = true, +}: { + benchmark: Benchmark; + accentColor?: string; + children: React.ReactNode; + rightText?: string; + showCategory?: boolean; +}) { + return ( +
+ {accentColor && ( +
+ )} +
+ +
+ {children} +
+ +
+
+ ); +} + +// ─── GET ─────────────────────────────────────────────────────────────── export async function GET( request: Request, { params }: { params: Promise<{ slug: string }> } @@ -33,109 +280,66 @@ export async function GET( | "compare" | "leaderboard"; + const providersParam = url.searchParams.get("providers"); + const providerSlugs = providersParam + ? providersParam.split(",").filter(Boolean) + : null; + const filtered = + providerSlugs && providerSlugs.length > 0 + ? { + ...benchmark, + results: benchmark.results.filter((r) => + providerSlugs.includes(r.slug) + ), + } + : benchmark; + + const safe = filtered.results.length > 0 ? filtered : benchmark; + const colors = buildProviderColors(benchmark.results); + switch (template) { case "snapshot": - return renderSnapshot(benchmark); + return renderSnapshot(safe, colors); case "headline": - return renderHeadline(benchmark); + return renderHeadline(safe, colors); case "compare": - return renderCompare(benchmark); + return renderCompare(safe, colors); case "leaderboard": - return renderLeaderboard(benchmark); + return renderLeaderboard(benchmark, colors); case "ranking": default: - return renderRanking(benchmark); + return renderRanking(benchmark, colors); } } +// ─── Ranking · vertical bars ─────────────────────────────────────────── async function renderRanking( - benchmark: NonNullable>> + benchmark: Benchmark, + colors: Map ) { const sorted = [...benchmark.results].sort((a, b) => a.ms.p50 - b.ms.p50); - const colors = buildProviderColors(benchmark.results); const maxP50 = Math.max(...sorted.map((r) => r.ms.p50)) || 1; - - // Chart geometry - const chartHeight = 320; - const barAreaPaddingTop = 60; + const chartHeight = 280; return new ImageResponse( ( -
- {/* Header */} -
-
-
-
- LIVE -
-
- {benchmark.category.toUpperCase()} -
-
- № {benchmark.number} · 24h -
-
+ +
{benchmark.title} @@ -143,230 +347,130 @@ async function renderRanking(
Provider ranking by p50 · ascending. Lower is faster.
-
- {/* Bars */} -
- {sorted.map((r) => { - const heightPx = Math.max( - 28, - (r.ms.p50 / maxP50) * chartHeight - ); - const color = colors.get(r.slug) ?? INK_SOFT; - return ( -
+
+ {sorted.map((r) => { + const heightPx = Math.max(28, (r.ms.p50 / maxP50) * chartHeight); + const color = colors.get(r.slug) ?? INK_SOFT; + return (
- {fmtValue(r.ms.p50, benchmark.unit)} - - {unitSuffix(benchmark.unit).trim()} - -
-
-
- {r.name} -
-
- p99 {fmtUnit(r.ms.p99, benchmark.unit)} + {fmtValue(r.ms.p50, benchmark.unit)} + + {unitSuffix(benchmark.unit).trim()} + +
+
+
+ {r.name} +
+
+ p99 {fmtUnit(r.ms.p99, benchmark.unit)} +
-
- ); - })} -
- - {/* Footer */} -
- openchainbench.xyz - - {benchmark.results.length} providers ·{" "} - {Math.round(benchmark.sampleSize).toLocaleString()} samples - + ); + })} +
-
+ ), { ...SIZE } ); } -async function renderSnapshot( - benchmark: NonNullable>> +// ─── Leaderboard · ranked rows ───────────────────────────────────────── +async function renderLeaderboard( + benchmark: Benchmark, + colors: Map ) { const sorted = [...benchmark.results].sort((a, b) => a.ms.p50 - b.ms.p50); - const colors = buildProviderColors(benchmark.results); - - const seriesList = sorted - .map((r) => ({ - slug: r.slug, - name: r.name, - values: benchmark.extras.series24h[r.slug] ?? [], - color: colors.get(r.slug) ?? INK_SOFT, - p50: r.ms.p50, - })) - .filter((s) => s.values.length > 1); - - // Chart geometry — internal SVG-style coordinate space - const chartW = 1060; - const chartH = 320; - const chartTop = 240; - const chartLeft = 70; - - const all = seriesList.flatMap((s) => s.values); - const min = all.length ? Math.min(...all) : 0; - const max = all.length ? Math.max(...all) : 1; - const range = max - min || 1; - const maxLen = Math.max(...seriesList.map((s) => s.values.length), 1); + const maxP50 = Math.max(...sorted.map((r) => r.ms.p50)) || 1; return new ImageResponse( ( -
- {/* Header */} -
-
-
-
- LIVE · 24H -
-
- {benchmark.category.toUpperCase()} -
-
+ +
{benchmark.title} @@ -374,887 +478,670 @@ async function renderSnapshot(
- {benchmark.subtitle} + Ranked by p50 · ascending. Lower is faster.
-
- - {/* Chart background */} -
- - {/* Lines (rendered via SVG) */} - - {seriesList.map(({ slug, values, color }) => { - const points = values - .map((v, i) => { - const x = (i / Math.max(1, maxLen - 1)) * chartW; - const y = - chartH - ((v - min) / range) * (chartH - 12) - 6; - return `${x.toFixed(2)},${y.toFixed(2)}`; - }) - .join(" "); - const last = values[values.length - 1]; - const lastX = - ((values.length - 1) / Math.max(1, maxLen - 1)) * chartW; - const lastY = chartH - ((last - min) / range) * (chartH - 12) - 6; - return ( - - - - - ); - })} - - - {/* Legend */} -
- {seriesList.map((s) => ( -
-
- {s.name} - - {fmtUnit(s.p50, benchmark.unit)} - -
- ))} -
- {/* Footer */} -
- openchainbench.xyz - - {Math.round(benchmark.sampleSize).toLocaleString()} samples · 24h - +
+ {sorted.map((r, i) => { + const color = colors.get(r.slug) ?? INK_SOFT; + const widthPct = Math.max(8, (r.ms.p50 / maxP50) * 100); + return ( +
+
+ {String(i + 1).padStart(2, "0")} +
+
+
+ + {r.name} + + + + {fmtValue(r.ms.p50, benchmark.unit)} + + + {unitSuffix(benchmark.unit).trim()} + + +
+
+
+
+
+
+ ); + })} +
-
+ ), { ...SIZE } ); } -// ─── Headline · big number poster ────────────────────────────────────── -async function renderHeadline( - benchmark: NonNullable>> +// ─── Snapshot · 24h multi-line ───────────────────────────────────────── +async function renderSnapshot( + benchmark: Benchmark, + colors: Map ) { const sorted = [...benchmark.results].sort((a, b) => a.ms.p50 - b.ms.p50); - const winner = sorted[0]; - const colors = buildProviderColors(benchmark.results); - const winnerColor = colors.get(winner?.slug ?? "") ?? INK; + const seriesList = sorted + .map((r) => ({ + slug: r.slug, + name: r.name, + values: benchmark.extras.series24h[r.slug] ?? [], + color: colors.get(r.slug) ?? INK_SOFT, + p50: r.ms.p50, + })) + .filter((s) => s.values.length > 1); + + const chartW = 1086; + const chartH = 280; + const all = seriesList.flatMap((s) => s.values); + const min = all.length ? Math.min(...all) : 0; + const max = all.length ? Math.max(...all) : 1; + const range = max - min || 1; + const maxLen = Math.max(...seriesList.map((s) => s.values.length), 1); return new ImageResponse( ( -
- {/* Top accent bar in winner's color */} -
- +
- {/* Eyebrow */}
+ {benchmark.title} +
+
-
-
- LIVE · 24H -
- OpenChainBench № {benchmark.number} + {benchmark.subtitle}
- {/* Headline metric */} + {/* Chart with subtle bg */}
+ {/* Y-axis labels */}
- {benchmark.title} · field min p50 -
-
- - {winner ? fmtValue(winner.ms.p50, benchmark.unit) : "—"} - - - {unitSuffix(benchmark.unit).trim()} - -
-
- by {winner?.name ?? "—"} + max {fmtUnit(max, benchmark.unit)}
- ahead of {sorted.length - 1} other provider{sorted.length === 2 ? "" : "s"} + min {fmtUnit(min, benchmark.unit)}
+ + + {seriesList.map(({ slug, values, color }) => { + const points = values + .map((v, i) => { + const x = (i / Math.max(1, maxLen - 1)) * chartW; + const y = + chartH - ((v - min) / range) * (chartH - 16) - 8; + return `${x.toFixed(2)},${y.toFixed(2)}`; + }) + .join(" "); + const last = values[values.length - 1]; + const lastX = + ((values.length - 1) / Math.max(1, maxLen - 1)) * chartW; + const lastY = + chartH - ((last - min) / range) * (chartH - 16) - 8; + return ( + + + + + ); + })} +
- {/* Footer */} + {/* Legend */}
- openchainbench.xyz - {benchmark.category} + {seriesList.map((s) => ( +
+
+ {s.name} + + {fmtUnit(s.p50, benchmark.unit)} + +
+ ))}
-
+ ), { ...SIZE } ); } -// ─── Compare · two-column head-to-head ───────────────────────────────── -async function renderCompare( - benchmark: NonNullable>> +// ─── Headline · big-number poster ────────────────────────────────────── +async function renderHeadline( + benchmark: Benchmark, + colors: Map ) { const sorted = [...benchmark.results].sort((a, b) => a.ms.p50 - b.ms.p50); - const top = sorted.slice(0, 2); - if (top.length < 2) return renderHeadline(benchmark); - - const colors = buildProviderColors(benchmark.results); - const [a, b] = top; - const aColor = colors.get(a.slug) ?? INK_SOFT; - const bColor = colors.get(b.slug) ?? INK_SOFT; - - const delta = b.ms.p50 - a.ms.p50; - const deltaPct = a.ms.p50 > 0 ? (delta / a.ms.p50) * 100 : 0; + const winner = sorted[0]; + const winnerColor = colors.get(winner?.slug ?? "") ?? INK; return new ImageResponse( ( -
- {/* Top header */} +
-
- LIVE + {benchmark.title} · field min p50
- {benchmark.title} · top 2 by p50 -
- - {/* Two-column body */} -
- {/* Left column */}
-
- Rank 1 -
-
+ - {a.name} -
-
- - {fmtValue(a.ms.p50, benchmark.unit)} - - - {unitSuffix(benchmark.unit).trim()} - -
-
- p50 -
- -
-
- - P99 - - {fmtUnit(a.ms.p99, benchmark.unit)} -
-
- - SUCCESS - - {a.successRate.toFixed(2)}% -
-
- - N · 24H - - {Math.round(a.sampleSize ?? 0).toLocaleString()} -
-
+ {unitSuffix(benchmark.unit).trim()} + +
+
+ by{" "} + + {winner?.name ?? "—"} + +
+
+ ahead of {Math.max(0, sorted.length - 1)} other provider + {sorted.length === 2 ? "" : "s"} + {winner && ` · p99 ${fmtUnit(winner.ms.p99, benchmark.unit)}`}
+
+ + ), + { ...SIZE } + ); +} + +// ─── Compare · top-2 head-to-head ────────────────────────────────────── +async function renderCompare( + benchmark: Benchmark, + colors: Map +) { + const sorted = [...benchmark.results].sort((a, b) => a.ms.p50 - b.ms.p50); + const top = sorted.slice(0, 2); + if (top.length < 2) return renderHeadline(benchmark, colors); + + const [a, b] = top; + const aColor = colors.get(a.slug) ?? INK_SOFT; + const bColor = colors.get(b.slug) ?? INK_SOFT; + + const delta = b.ms.p50 - a.ms.p50; + const deltaPct = a.ms.p50 > 0 ? (delta / a.ms.p50) * 100 : 0; - {/* Center divider with delta */} + return new ImageResponse( + ( + +
-
- Δ p50 -
-
- {fmtUnit(Math.abs(delta), benchmark.unit)} -
-
- {deltaPct >= 0 ? "+" : ""}{deltaPct.toFixed(0)}% -
+ {benchmark.title} · top 2
- {/* Right column */}
+ {/* Left */} + + {/* Center divider */}
- Rank 2 -
-
- {b.name} -
-
- - {fmtValue(b.ms.p50, benchmark.unit)} - - - {unitSuffix(benchmark.unit).trim()} - -
-
- p50 -
- -
-
- - P99 - - {fmtUnit(b.ms.p99, benchmark.unit)} + Δ p50
-
- - SUCCESS - - {b.successRate.toFixed(2)}% +
+ {fmtUnit(Math.abs(delta), benchmark.unit)}
-
- - N · 24H - - {Math.round(b.sampleSize ?? 0).toLocaleString()} +
+ {deltaPct >= 0 ? "+" : ""} + {deltaPct.toFixed(0)}%
+ {/* Right */} +
+ + ), + { ...SIZE } + ); +} - {/* Footer */} -
+
+ Rank {String(rank).padStart(2, "0")} +
+
+ {name} +
+
+ - openchainbench.xyz - {benchmark.category} -
+ {p50} + + + {unit} + +
+
+ p50
- ), - { ...SIZE } - ); -} - -// ─── Leaderboard · ranked rows with mini bars ────────────────────────── -async function renderLeaderboard( - benchmark: NonNullable>> -) { - const sorted = [...benchmark.results].sort((a, b) => a.ms.p50 - b.ms.p50); - const colors = buildProviderColors(benchmark.results); - const maxP50 = Math.max(...sorted.map((r) => r.ms.p50)) || 1; - return new ImageResponse( - (
- {/* Header */} -
-
+ -
-
- LIVE · 24H -
- {benchmark.category} -
-
+ {p99} +
+
+ - {benchmark.title} -
-
+ {successPct.toFixed(2)}% +
+
+ - Ranked by p50 · ascending -
-
- - {/* Rows */} -
- {sorted.map((r, i) => { - const color = colors.get(r.slug) ?? INK_SOFT; - const widthPct = Math.max(8, (r.ms.p50 / maxP50) * 100); - return ( -
-
- {String(i + 1).padStart(2, "0")} -
-
-
- - {r.name} - - - - {fmtValue(r.ms.p50, benchmark.unit)} - - - {unitSuffix(benchmark.unit).trim()} - - -
-
-
-
-
-
- ); - })} -
- - {/* Footer */} -
- openchainbench.xyz - - {Math.round(benchmark.sampleSize).toLocaleString()} samples · 24h + N · 24H + {Math.round(n).toLocaleString()}
- ), - { ...SIZE } +
); } diff --git a/src/components/share-section.tsx b/src/components/share-section.tsx index e45588c4..ad9219a0 100644 --- a/src/components/share-section.tsx +++ b/src/components/share-section.tsx @@ -1,12 +1,15 @@ "use client"; -import { useState } from "react"; -import { Download, ChevronDown } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { Download, ChevronDown, Loader2 } from "lucide-react"; +import type { Benchmark } from "@/types/benchmark"; type Template = { id: string; label: string; description: string; + /** Whether this template lets the reader filter providers. */ + filterable: boolean; }; const TEMPLATES: Template[] = [ @@ -14,42 +17,87 @@ const TEMPLATES: Template[] = [ id: "ranking", label: "Ranking", description: "Vertical bars sorted ascending by p50, with provider names and p99 tails.", + filterable: false, }, { id: "leaderboard", label: "Leaderboard", description: "Ranked rows with horizontal mini-bars in each provider's signature color.", + filterable: false, }, { id: "snapshot", label: "Snapshot", description: "Full 24-hour multi-line chart with per-provider legend at the bottom.", + filterable: true, }, { id: "headline", label: "Headline", description: "Big-number poster — the field's fastest p50 in the winner's color.", + filterable: true, }, { id: "compare", label: "Compare", description: "Top-2 head-to-head with p50 / p99 / success / sample-size and the delta between them.", + filterable: true, }, ]; type Props = { slug: string; title: string; + benchmark: Benchmark; }; -export function ShareSection({ slug, title }: Props) { +export function ShareSection({ slug, title, benchmark }: Props) { const [activeId, setActiveId] = useState("ranking"); - const cardSrc = (templateId: string) => - `/benchmarks/${slug}/share-card?template=${templateId}`; + // Sort providers ascending p50 for the toggle UI — same order as the + // legend / ledger. + const orderedProviders = useMemo( + () => + [...benchmark.results].sort((a, b) => a.ms.p50 - b.ms.p50).map((r) => ({ + slug: r.slug, + name: r.name, + })), + [benchmark] + ); + + // Default: all providers selected. + const [selected, setSelected] = useState>( + () => new Set(orderedProviders.map((p) => p.slug)) + ); + + function toggleProvider(slug: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(slug)) next.delete(slug); + else next.add(slug); + return next; + }); + } + + const activeTemplate = TEMPLATES.find((t) => t.id === activeId); + const showFilter = activeTemplate?.filterable ?? false; + + // Build the URL with optional providers filter. + const cardSrc = (templateId: string, applyFilter: boolean) => { + const tpl = TEMPLATES.find((t) => t.id === templateId); + const base = `/benchmarks/${slug}/share-card?template=${templateId}`; + if (!applyFilter || !tpl?.filterable) return base; + if (selected.size === orderedProviders.length) return base; // all selected ⇒ no param + if (selected.size === 0) return base; // nothing selected ⇒ no param (server falls back) + const list = orderedProviders + .filter((p) => selected.has(p.slug)) + .map((p) => p.slug) + .join(","); + return `${base}&providers=${encodeURIComponent(list)}`; + }; async function handleDownload(templateId: string) { - const url = cardSrc(templateId); + const url = cardSrc(templateId, true); try { const res = await fetch(url); const blob = await res.blob(); @@ -105,17 +153,44 @@ export function ShareSection({ slug, title }: Props) { return (

{t.description}

-
- {/* eslint-disable-next-line @next/next/no-img-element */} - {`${title} -
+ + {/* Provider filter (only on filterable templates) */} + {showFilter && ( +
+ + Providers + + {orderedProviders.map((p) => { + const isOn = selected.has(p.slug); + return ( + + ); + })} + +
+ )} + +