diff --git a/src/app/benchmarks/[slug]/share-card/route.tsx b/src/app/benchmarks/[slug]/share-card/route.tsx index 3ecc6c15..ad027e4a 100644 --- a/src/app/benchmarks/[slug]/share-card/route.tsx +++ b/src/app/benchmarks/[slug]/share-card/route.tsx @@ -529,12 +529,67 @@ export async function GET( const chainOption = isAll ? null : chainOptions.find((c) => matchesChainSlug(c.value, chainParam)) ?? null; - const benchmark = chainOption - ? (await getBenchmark(slug, { chain: chainOption.value })) ?? aggregate - : aggregate; + + // Additional dimension filters. Symmetric with `chain`: each declared + // dimension gets a URL param, and when the value matches a spec option + // it gets forwarded to the materialize loader so the exported PNG + // renders the exact scope the user is looking at on the page. `all` + // means "no filter for this dimension" (same convention as chain). + const regionParam = url.searchParams.get("region"); + const regionOptions = aggregate.dimensions?.region ?? []; + const regionOption = + !regionParam || regionParam === "all" + ? null + : regionOptions.find((r) => r.value === regionParam) ?? null; + + const kindParam = url.searchParams.get("kind"); + const kindOptions = aggregate.dimensions?.kind ?? []; + const kindOption = + !kindParam || kindParam === "all" + ? null + : kindOptions.find((k) => k.value === kindParam) ?? null; + + const venueParam = url.searchParams.get("venue"); + const venueOptions = aggregate.dimensions?.venue ?? []; + const venueOption = + !venueParam || venueParam === "all" + ? null + : venueOptions.find((v) => v.value === venueParam) ?? null; + + const filters: { + chain?: string; + region?: string; + kind?: string; + venue?: string; + } = {}; + if (chainOption) filters.chain = chainOption.value; + if (regionOption) filters.region = regionOption.value; + if (kindOption) filters.kind = kindOption.value; + if (venueOption) filters.venue = venueOption.value; + + const benchmark = + Object.keys(filters).length > 0 + ? (await getBenchmark(slug, filters)) ?? aggregate + : aggregate; // No pill for `all` either - it's the unfiltered default view, calling // it out as a "chain" reads awkward. const chainLabel = chainOption?.label ?? null; + const regionLabel = regionOption?.label ?? null; + const kindLabel = kindOption?.label ?? null; + const venueLabel = venueOption?.label ?? null; + // Composed context suffix for the card title. Appends the active + // filters after the bench title so a card exported from + // `/benchmarks/rpc-capabilities?chain=ethereum®ion=sgp` renders + // "Fastest free public RPC ... · Ethereum · Singapore" instead of just + // the aggregate title. Empty when no filter is active - avoids a + // trailing separator on the default view. + const contextParts = [chainLabel, regionLabel, kindLabel, venueLabel].filter( + (p): p is string => Boolean(p), + ); + const displayTitle = + contextParts.length > 0 + ? `${aggregate.title} · ${contextParts.join(" · ")}` + : aggregate.title; const rawTemplate = url.searchParams.get("template"); const template: "ranking" | "snapshot" | "headline" | "compare" | "leaderboard" = @@ -583,18 +638,26 @@ export async function GET( const colors = buildProviderColors(benchmark.results); + // Overlay the composed title onto the benchmark that each render + // receives, so the existing `{benchmark.title}` slot in every template + // picks up the filter context without touching every render signature. + // Display-only overlay: keeps the underlying benchmark object intact + // for data purposes (results, dimensions, unit, higherIsBetter etc). + const filteredWithTitle = { ...filteredSafe, title: displayTitle }; + const benchmarkWithTitle = { ...benchmark, title: displayTitle }; + switch (template) { case "snapshot": - return renderSnapshot(filteredSafe, colors, chainLabel); + return renderSnapshot(filteredWithTitle, colors, chainLabel); case "headline": - return renderHeadline(benchmark, colors, headlineProvider, chainLabel); + return renderHeadline(benchmarkWithTitle, colors, headlineProvider, chainLabel); case "compare": - return renderCompare(benchmark, colors, compareA, compareB, chainLabel); + return renderCompare(benchmarkWithTitle, colors, compareA, compareB, chainLabel); case "leaderboard": - return renderLeaderboard(benchmark, colors, chainLabel); + return renderLeaderboard(benchmarkWithTitle, colors, chainLabel); case "ranking": default: - return renderRanking(benchmark, colors, chainLabel); + return renderRanking(benchmarkWithTitle, colors, chainLabel); } } diff --git a/src/components/share-section-modal.tsx b/src/components/share-section-modal.tsx index effb82ee..5c1d67c0 100644 --- a/src/components/share-section-modal.tsx +++ b/src/components/share-section-modal.tsx @@ -139,14 +139,28 @@ export default function ShareSectionModal({ // Build the URL with the right params per template. const cardSrc = (templateId: string) => { const tpl = TEMPLATES.find((t) => t.id === templateId); - // Read the chain from the live URL so the share-card stays in sync - // when the user flips chain tabs client-side. `chain` prop is the - // server-rendered fallback for the very first render. - const liveChain = + // Read every dimension filter from the live URL so the share-card + // stays in sync when the user flips a chain / region / kind / venue + // tab client-side. `chain` prop is the server-rendered fallback for + // the very first render; the other dimensions are read from the URL + // only (they're not passed as props today, and the pattern reads + // whatever the page's state has serialised). + const liveUrl = typeof window !== "undefined" - ? new URL(window.location.href).searchParams.get("chain") - : chain ?? null; + ? new URL(window.location.href) + : null; + const liveChain = liveUrl + ? liveUrl.searchParams.get("chain") + : chain ?? null; const chainParam = liveChain ? `&chain=${encodeURIComponent(liveChain)}` : ""; + const liveRegion = liveUrl ? liveUrl.searchParams.get("region") : null; + const regionParam = liveRegion + ? `®ion=${encodeURIComponent(liveRegion)}` + : ""; + const liveKind = liveUrl ? liveUrl.searchParams.get("kind") : null; + const kindParam = liveKind ? `&kind=${encodeURIComponent(liveKind)}` : ""; + const liveVenue = liveUrl ? liveUrl.searchParams.get("venue") : null; + const venueParam = liveVenue ? `&venue=${encodeURIComponent(liveVenue)}` : ""; // Mirror the active site theme so the exported PNG matches what the // user is looking at. SSR can't read the dark state - default to light // server-side, the client re-renders with `dark` once mounted. @@ -154,7 +168,7 @@ export default function ShareSectionModal({ typeof window !== "undefined" && document.documentElement.classList.contains("dark"); const themeParam = isDark ? "&theme=dark" : ""; - const base = `/benchmarks/${slug}/share-card?template=${templateId}${chainParam}${themeParam}`; + const base = `/benchmarks/${slug}/share-card?template=${templateId}${chainParam}${regionParam}${kindParam}${venueParam}${themeParam}`; if (!tpl) return base; if (tpl.pick === "multi") { if ( diff --git a/src/components/share-section.tsx b/src/components/share-section.tsx index f45846f4..f3722113 100644 --- a/src/components/share-section.tsx +++ b/src/components/share-section.tsx @@ -125,14 +125,25 @@ export function ShareSection({ slug, title, benchmark, chain }: Props) { // Build the URL with the right params per template. const cardSrc = (templateId: string) => { const tpl = TEMPLATES.find((t) => t.id === templateId); - // Read the chain from the live URL so the share-card stays in sync - // when the user flips chain tabs client-side. `chain` prop is the - // server-rendered fallback for the very first render. - const liveChain = + // Read every dimension filter from the live URL so the share-card + // stays in sync when the user flips a chain / region / kind / venue + // tab client-side. + const liveUrl = typeof window !== "undefined" - ? new URL(window.location.href).searchParams.get("chain") - : chain ?? null; + ? new URL(window.location.href) + : null; + const liveChain = liveUrl + ? liveUrl.searchParams.get("chain") + : chain ?? null; const chainParam = liveChain ? `&chain=${encodeURIComponent(liveChain)}` : ""; + const liveRegion = liveUrl ? liveUrl.searchParams.get("region") : null; + const regionParam = liveRegion + ? `®ion=${encodeURIComponent(liveRegion)}` + : ""; + const liveKind = liveUrl ? liveUrl.searchParams.get("kind") : null; + const kindParam = liveKind ? `&kind=${encodeURIComponent(liveKind)}` : ""; + const liveVenue = liveUrl ? liveUrl.searchParams.get("venue") : null; + const venueParam = liveVenue ? `&venue=${encodeURIComponent(liveVenue)}` : ""; // Mirror the active site theme so the exported PNG matches what the // user is looking at. SSR can't read the dark state - default to light // server-side, the client re-renders with `dark` once mounted. @@ -140,7 +151,7 @@ export function ShareSection({ slug, title, benchmark, chain }: Props) { typeof window !== "undefined" && document.documentElement.classList.contains("dark"); const themeParam = isDark ? "&theme=dark" : ""; - const base = `/benchmarks/${slug}/share-card?template=${templateId}${chainParam}${themeParam}`; + const base = `/benchmarks/${slug}/share-card?template=${templateId}${chainParam}${regionParam}${kindParam}${venueParam}${themeParam}`; if (!tpl) return base; if (tpl.pick === "multi") { if (