diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx index c71076ae..488a6aa3 100644 --- a/src/app/benchmarks/[slug]/page.tsx +++ b/src/app/benchmarks/[slug]/page.tsx @@ -13,6 +13,7 @@ import { TimeSeriesChart } from "@/components/time-series-chart"; import { LedgerTable } from "@/components/ledger-table"; import { RegionGrid } from "@/components/region-grid"; import { BigNumber } from "@/components/big-number"; +import { ShareSection } from "@/components/share-section"; import { fmtUnit, unitSuffix, fmtValue } from "@/lib/format"; type Params = { slug: string }; @@ -205,45 +206,20 @@ export default async function BenchmarkPage({ - {/* Reproduce / cite — collapsed */} + {/* Share / export */} {!isDraft && ( -
- - - Reproduce · cite · source - - - -
-
-

- Source code -

- - {benchmark.source.replace("https://github.com/", "github.com/")} - - -
-
-

- BibTeX -

-
-{`@misc{openchainbench-${benchmark.number},
-  author       = {{OpenChainBench}},
-  title        = {${benchmark.title}},
-  year         = {${new Date(benchmark.lastRunAt).getFullYear()}},
-  howpublished = {\\url{https://openchainbench.xyz/benchmarks/${benchmark.slug}}},
-  note         = {Run on ${formatLastRun(benchmark.lastRunAt)}}
-}`}
-              
-
-
-
+ + )} + + {/* Source code link — bottom of page */} + {!isDraft && ( +

+ Source code{" "} + + {benchmark.source.replace("https://github.com/", "github.com/")} + + +

)} {/* Other benchmarks */} diff --git a/src/app/benchmarks/[slug]/share-card/route.tsx b/src/app/benchmarks/[slug]/share-card/route.tsx new file mode 100644 index 00000000..d809eb08 --- /dev/null +++ b/src/app/benchmarks/[slug]/share-card/route.tsx @@ -0,0 +1,500 @@ +import { ImageResponse } from "next/og"; +import { getBenchmark } from "@/data/benchmarks"; +import { buildProviderColors } from "@/lib/series-colors"; +import { fmtUnit, fmtValue, unitSuffix } from "@/lib/format"; + +export const runtime = "nodejs"; + +const SIZE = { width: 1200, height: 630 }; +const PAPER = "#f8f3eb"; +const PAPER_SOFT = "#f3eee0"; +const INK = "#181614"; +const INK_SOFT = "#4a443c"; +const INK_MUTED = "#7a7166"; +const INK_FAINT = "#a59b87"; +const RULE = "rgba(24, 22, 20, 0.12)"; +const GOOD = "#3d6d3d"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ slug: string }> } +) { + const { slug } = await params; + const benchmark = await getBenchmark(slug); + if (!benchmark) { + return new Response("Not found", { status: 404 }); + } + + const url = new URL(request.url); + const template = (url.searchParams.get("template") ?? "ranking") as + | "ranking" + | "snapshot"; + + if (template === "snapshot") { + return renderSnapshot(benchmark); + } + return renderRanking(benchmark); +} + +async function renderRanking( + 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; + + // Chart geometry + const chartHeight = 320; + const barAreaPaddingTop = 60; + + return new ImageResponse( + ( +
+ {/* Header */} +
+
+
+
+ LIVE +
+
+ {benchmark.category.toUpperCase()} +
+
+ № {benchmark.number} · 24h +
+
+
+ {benchmark.title} +
+
+ 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 ( +
+
+ {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>> +) { + 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); + + return new ImageResponse( + ( +
+ {/* Header */} +
+
+
+
+ LIVE · 24H +
+
+ {benchmark.category.toUpperCase()} +
+
+
+ {benchmark.title} +
+
+ {benchmark.subtitle} +
+
+ + {/* 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 + +
+
+ ), + { ...SIZE } + ); +} diff --git a/src/components/share-section.tsx b/src/components/share-section.tsx new file mode 100644 index 00000000..2d1784f2 --- /dev/null +++ b/src/components/share-section.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useState } from "react"; +import { Download, ChevronDown } from "lucide-react"; + +type Template = { + id: string; + label: string; + description: string; +}; + +const TEMPLATES: Template[] = [ + { + id: "ranking", + label: "Ranking", + description: "Provider bars sorted by p50, with values and tail at p99.", + }, + { + id: "snapshot", + label: "Snapshot", + description: "24-hour multi-line chart of every provider, with a legend.", + }, +]; + +type Props = { + slug: string; + title: string; +}; + +export function ShareSection({ slug, title }: Props) { + const [activeId, setActiveId] = useState("ranking"); + + const cardSrc = (templateId: string) => + `/benchmarks/${slug}/share-card?template=${templateId}`; + + async function handleDownload(templateId: string) { + const url = cardSrc(templateId); + try { + const res = await fetch(url); + const blob = await res.blob(); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `${slug}-${templateId}.png`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(a.href); + } catch (err) { + console.error("download failed", err); + } + } + + return ( +
+ + + Share · export · embed + + + +
+

+ Pick a layout and download a 1200×630 PNG ready for Twitter, Reddit, LinkedIn or any OG-card embed. Same data, same colors as this dashboard. +

+ + {/* Tabs */} +
+ {TEMPLATES.map((t) => ( + + ))} +
+ + {/* Active preview */} + {TEMPLATES.map((t) => { + if (t.id !== activeId) return null; + return ( +
+

{t.description}

+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`${title} +
+
+ + + Open raw ↗ + +
+
+ ); + })} +
+
+ ); +}