From 92c8b929d6f3f18275238767e0dfd62589fef115 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Mon, 13 Jul 2026 13:02:48 -0400 Subject: [PATCH] open graph image --- .../[section]/opengraph-image.tsx | 66 +++ .../canvas/opengraph-image.tsx | 71 +++ src/app/economic-indicators/og-card.tsx | 454 ++++++++++++++++++ .../economic-indicators/opengraph-image.tsx | 57 +++ 4 files changed, 648 insertions(+) create mode 100644 src/app/economic-indicators/[section]/opengraph-image.tsx create mode 100644 src/app/economic-indicators/canvas/opengraph-image.tsx create mode 100644 src/app/economic-indicators/og-card.tsx create mode 100644 src/app/economic-indicators/opengraph-image.tsx diff --git a/src/app/economic-indicators/[section]/opengraph-image.tsx b/src/app/economic-indicators/[section]/opengraph-image.tsx new file mode 100644 index 0000000..94853a6 --- /dev/null +++ b/src/app/economic-indicators/[section]/opengraph-image.tsx @@ -0,0 +1,66 @@ +import { ImageResponse } from "next/og"; +import { getEconomicSeries, humanizeSourceName } from "@/lib/api/economy"; +import { SECTIONS } from "../indicators"; +import { + buildOgChart, + IndicatorOgCard, + loadOgFonts, + OG_SIZE, +} from "../og-card"; + +export const runtime = "nodejs"; + +export const alt = "Build Canada — Economic Indicators"; + +export const size = OG_SIZE; + +export const contentType = "image/png"; + +export const revalidate = 3600; + +export default async function OpengraphImage({ + params, +}: { + params: Promise<{ section: string }>; +}) { + const { section: sectionId } = await params; + const section = SECTIONS.find((s) => s.id === sectionId); + + const title = section?.title ?? "Economic Indicators"; + const description = + section?.description ?? + "Are we moving in the right direction? Canadian prosperity, measured against the G7 and OECD."; + const featured = section + ? section.indicators.find((i) => i.slug === section.featuredSlug) + : undefined; + + const response = featured + ? await getEconomicSeries(featured.slug).catch(() => null) + : null; + const chart = response ? buildOgChart(response) : null; + const chartHeading = featured?.heading ?? ""; + const source = response?.meta.source; + const footnote = source ? `Source: ${humanizeSourceName(source.name)}` : ""; + + const fonts = await loadOgFonts( + `${title}${description}${chartHeading}${chart?.latestLabel ?? ""}${footnote}Economic Indicators`, + ); + + return new ImageResponse( + , + { + ...size, + fonts, + headers: { + "Cache-Control": "public, max-age=3600, s-maxage=3600", + }, + }, + ); +} diff --git a/src/app/economic-indicators/canvas/opengraph-image.tsx b/src/app/economic-indicators/canvas/opengraph-image.tsx new file mode 100644 index 0000000..65229f4 --- /dev/null +++ b/src/app/economic-indicators/canvas/opengraph-image.tsx @@ -0,0 +1,71 @@ +import { ImageResponse } from "next/og"; +import { getEconomicSeries } from "@/lib/api/economy"; +import { + buildOverlayOgChart, + IndicatorOgCard, + loadOgFonts, + OG_SIZE, +} from "../og-card"; + +export const runtime = "nodejs"; + +export const alt = + "Build Canada — Indicator Canvas: overlay economic indicator series"; + +export const size = OG_SIZE; + +export const contentType = "image/png"; + +export const revalidate = 3600; + +const TITLE = "Indicator Canvas"; +const DESCRIPTION = + "Overlay up to three economic indicator series and eyeball how they move together."; + +// A representative overlay in the canvas feed palette (FEED_COLORS in +// CanvasClient): growth, housing, and emissions on one normalized chart. +const FEEDS = [ + { slug: "gdp-per-capita-ppp", label: "GDP per capita", color: "#c43e3e" }, + { slug: "real-house-price-index", label: "House prices", color: "#0880b5" }, + { + slug: "co2-emissions-per-capita", + label: "CO₂ per capita", + color: "#17794d", + }, +]; + +export default async function OpengraphImage() { + const responses = await Promise.all( + FEEDS.map((feed) => + getEconomicSeries(feed.slug) + .then((response) => ({ response, color: feed.color })) + .catch(() => null), + ), + ); + const loaded = responses.filter((r) => r !== null); + const chart = loaded.length > 0 ? buildOverlayOgChart(loaded) : null; + + const legendText = FEEDS.map((f) => f.label).join(" "); + const fonts = await loadOgFonts( + `${TITLE}${DESCRIPTION}${legendText}Canada, three series overlaid₂`, + ); + + return new ImageResponse( + ({ label: f.label, color: f.color }))} + footnote="Each series normalized to its own range" + />, + { + ...size, + fonts, + headers: { + "Cache-Control": "public, max-age=3600, s-maxage=3600", + }, + }, + ); +} diff --git a/src/app/economic-indicators/og-card.tsx b/src/app/economic-indicators/og-card.tsx new file mode 100644 index 0000000..3b73de5 --- /dev/null +++ b/src/app/economic-indicators/og-card.tsx @@ -0,0 +1,454 @@ +import type { EconomySeriesResponse } from "@/lib/api/economy"; +import { CANADA_COLOR } from "./indicators"; +import { formatValue } from "./units"; + +// Shared plumbing for the /economic-indicators opengraph images: one branded +// card layout plus builders that bake a series into a data-URI SVG. Satori +// lays out the text; the chart travels as an so resvg rasterizes real +// vector paths instead of satori's partial SVG support. + +export const OG_SIZE = { width: 1200, height: 630 }; + +export const CHART_W = 1004; +export const CHART_H = 218; + +// Matches src/lib/og-image-template.tsx so every Build Canada card shares +// one look. +const theme = { + background: "#f6ece3", + accent: "#932f2f", + foreground: "#272727", + foregroundMuted: "#5d5d5d", + foregroundFaint: "#888888", + border: "rgba(39, 39, 39, 0.18)", + peerLine: "rgba(39, 39, 39, 0.16)", +} as const; + +const sans = "Inter, system-ui, -apple-system, sans-serif"; + +// Mirrors SectionSparkline: century-scale series (CO₂ reaches back to 1750) +// would render as a long flat tail at card size — preview the recent era. +const MAX_SPAN_YEARS = 60; +// Keeps strokes and the end dot inside the SVG canvas. +const PAD = 10; + +type ScaledSeries = { name: string; path: string; endX: number; endY: number }; + +function windowSeries(response: EconomySeriesResponse) { + const allYears = response.data.series.flatMap((s) => + s.points.map((p) => p.year), + ); + if (allYears.length === 0) return null; + const windowStart = Math.max(...allYears) - MAX_SPAN_YEARS; + const series = response.data.series + .map((s) => ({ + ...s, + points: s.points.filter((p) => p.year >= windowStart), + })) + .filter((s) => s.points.length >= 2); + return series.length > 0 ? series : null; +} + +function toPath( + points: { year: number; value: number }[], + toX: (year: number) => number, + toY: (value: number) => number, +): string { + return points + .map( + (p, i) => + `${i === 0 ? "M" : "L"} ${toX(p.year).toFixed(1)} ${toY(p.value).toFixed(1)}`, + ) + .join(" "); +} + +function svgDataUri(body: string): string { + const svg = `${body}`; + return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`; +} + +function line(path: string, stroke: string, width: number, opacity = 1): string { + return ``; +} + +function endDot(x: number, y: number, fill: string): string { + return ``; +} + +// Monthly points carry an ISO first-of-month date; annual points only a year. +function timeLabel(point: { year: number; date?: string }): string { + if (point.date) { + const parsed = new Date(point.date); + if (!Number.isNaN(parsed.getTime())) { + return new Intl.DateTimeFormat("en-CA", { + month: "short", + year: "numeric", + timeZone: "UTC", + }).format(parsed); + } + } + return String(Math.floor(point.year)); +} + +export type OgChart = { + dataUri: string; + minLabel: string; + maxLabel: string; + // "Canada $69,672 (2023)" — null when the series has no Canada line. + latestLabel: string | null; + hasPeers: boolean; +}; + +// Canada in brand red over muted peer lines — the SectionSparkline treatment +// at share-card scale. +export function buildOgChart(response: EconomySeriesResponse): OgChart | null { + const series = windowSeries(response); + if (!series) return null; + + const allPoints = series.flatMap((s) => s.points); + const minYear = Math.min(...allPoints.map((p) => p.year)); + const maxYear = Math.max(...allPoints.map((p) => p.year)); + const minValue = Math.min(...allPoints.map((p) => p.value)); + const maxValue = Math.max(...allPoints.map((p) => p.value)); + if (minYear === maxYear) return null; + + const valueSpan = maxValue - minValue; + const toX = (year: number) => + PAD + ((year - minYear) / (maxYear - minYear)) * (CHART_W - 2 * PAD); + const toY = (value: number) => + valueSpan === 0 + ? CHART_H / 2 + : CHART_H - + PAD - + ((value - minValue) / valueSpan) * (CHART_H - 2 * PAD); + + const scaled: ScaledSeries[] = series.map((s) => { + const last = s.points[s.points.length - 1]; + return { + name: s.jurisdiction.name, + path: toPath(s.points, toX, toY), + endX: toX(last.year), + endY: toY(last.value), + }; + }); + + const canada = scaled.find((s) => s.name === "Canada"); + const peers = scaled.filter((s) => s !== canada); + + const body = [ + ...peers.map((p) => line(p.path, theme.foreground, 2, 0.16)), + ...(canada + ? [ + line(canada.path, CANADA_COLOR, 4.5), + endDot(canada.endX, canada.endY, CANADA_COLOR), + ] + : []), + ].join(""); + + const canadaSeries = series.find((s) => s.jurisdiction.name === "Canada"); + const lastPoint = canadaSeries?.points[canadaSeries.points.length - 1]; + const latestLabel = lastPoint + ? `Canada ${formatValue(lastPoint.value, response.data.measure.unit.symbol)} (${timeLabel(lastPoint)})` + : null; + + const firstPoints = allPoints.filter((p) => p.year === minYear); + const lastPoints = allPoints.filter((p) => p.year === maxYear); + + return { + dataUri: svgDataUri(body), + minLabel: timeLabel(firstPoints[0] ?? { year: minYear }), + maxLabel: timeLabel(lastPoints[0] ?? { year: maxYear }), + latestLabel, + hasPeers: peers.length > 0, + }; +} + +// Canvas preview: each feed's Canada series normalized to 0–1 and overlaid in +// the canvas feed palette — the same visual the page itself produces. +export function buildOverlayOgChart( + feeds: { response: EconomySeriesResponse; color: string }[], +): Pick | null { + const lines: { color: string; points: { year: number; value: number }[] }[] = + []; + for (const feed of feeds) { + const series = windowSeries(feed.response); + const canada = + series?.find((s) => s.jurisdiction.name === "Canada") ?? series?.[0]; + if (canada) lines.push({ color: feed.color, points: canada.points }); + } + if (lines.length === 0) return null; + + const allYears = lines.flatMap((l) => l.points.map((p) => p.year)); + const minYear = Math.min(...allYears); + const maxYear = Math.max(...allYears); + if (minYear === maxYear) return null; + + const toX = (year: number) => + PAD + ((year - minYear) / (maxYear - minYear)) * (CHART_W - 2 * PAD); + + const body = lines + .map((l) => { + const values = l.points.map((p) => p.value); + const min = Math.min(...values); + const span = Math.max(...values) - min; + const toY = (value: number) => + span === 0 + ? CHART_H / 2 + : CHART_H - PAD - ((value - min) / span) * (CHART_H - 2 * PAD); + const last = l.points[l.points.length - 1]; + return ( + line(toPath(l.points, toX, toY), l.color, 4) + + endDot(toX(last.year), toY(last.value), l.color) + ); + }) + .join(""); + + return { + dataUri: svgDataUri(body), + minLabel: String(Math.floor(minYear)), + maxLabel: String(Math.floor(maxYear)), + }; +} + +export function IndicatorOgCard({ + label, + title, + description, + chartHeading, + chart, + legend, + footnote, +}: { + label: string; + title: string; + description: string; + chartHeading: string; + chart: + | (Pick & + Partial>) + | null; + legend?: { label: string; color: string }[]; + footnote?: string; +}) { + return ( +
+
+ +
+ + BUILD CANADA + +
+ + {label} + +
+ +

+ {title} +

+ +

+ {description} +

+ +
+ + {chart && ( +
+
+ + {chartHeading} + + {legend ? ( +
+ {legend.map((item) => ( +
+
+ + {item.label} + +
+ ))} +
+ ) : chart.latestLabel ? ( + + {chart.latestLabel} + + ) : null} +
+ + + +
+ {chart.minLabel} + {chart.maxLabel} +
+
+ )} + +
+ + {footnote ?? ""} + + + buildcanada.com + +
+
+ ); +} + +// Same Google Fonts subsetting trick as the bills OG images: fetch only the +// glyphs the card renders, fall back to satori's default font on failure. +async function loadGoogleFont(font: string, weight: number, text: string) { + const params = new URLSearchParams({ + family: `${font}:wght@${weight}`, + text, + }); + const css = await ( + await fetch(`https://fonts.googleapis.com/css2?${params.toString()}`) + ).text(); + const resource = css.match( + /src: url\((.+?)\) format\('(opentype|truetype|woff2)'\)/, + ); + if (resource) { + const res = await fetch(resource[1]); + if (res.status === 200) return await res.arrayBuffer(); + } + throw new Error("failed to load font data"); +} + +export async function loadOgFonts(text: string) { + const subset = `${text} BUILDCANAbuildcanada.com0123456789·$%,.()—–&`; + try { + const [regular, bold] = await Promise.all([ + loadGoogleFont("Inter", 400, subset), + loadGoogleFont("Inter", 700, subset), + ]); + return [ + { name: "Inter", data: regular, weight: 400 as const, style: "normal" as const }, + { name: "Inter", data: bold, weight: 700 as const, style: "normal" as const }, + ]; + } catch { + return undefined; + } +} diff --git a/src/app/economic-indicators/opengraph-image.tsx b/src/app/economic-indicators/opengraph-image.tsx new file mode 100644 index 0000000..9d22d31 --- /dev/null +++ b/src/app/economic-indicators/opengraph-image.tsx @@ -0,0 +1,57 @@ +import { ImageResponse } from "next/og"; +import { getEconomicSeries, humanizeSourceName } from "@/lib/api/economy"; +import { indicatorHeading } from "./indicators"; +import { + buildOgChart, + IndicatorOgCard, + loadOgFonts, + OG_SIZE, +} from "./og-card"; + +export const runtime = "nodejs"; + +export const alt = + "Build Canada — Economic Indicators: Canadian prosperity, measured against the G7 and OECD"; + +export const size = OG_SIZE; + +export const contentType = "image/png"; + +export const revalidate = 3600; + +// The landing card leads with the dashboard's clearest single chart. +const FEATURED_SLUG = "gdp-per-capita-ppp"; + +const TITLE = "Economic Indicators"; +const DESCRIPTION = + "Are we moving in the right direction? Canadian prosperity, measured against the G7 and OECD."; + +export default async function OpengraphImage() { + const response = await getEconomicSeries(FEATURED_SLUG).catch(() => null); + const chart = response ? buildOgChart(response) : null; + const chartHeading = indicatorHeading(FEATURED_SLUG); + const source = response?.meta.source; + const footnote = source ? `Source: ${humanizeSourceName(source.name)}` : ""; + + const fonts = await loadOgFonts( + `${TITLE}${DESCRIPTION}${chartHeading}${chart?.latestLabel ?? ""}${footnote}`, + ); + + return new ImageResponse( + , + { + ...size, + fonts, + headers: { + "Cache-Control": "public, max-age=3600, s-maxage=3600", + }, + }, + ); +}