diff --git a/package.json b/package.json index 313bed9..c70dee7 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,10 @@ "@astrojs/mdx": "^4.2.0", "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", + "@resvg/resvg-js": "^2.6.2", "astro": "^5.6.0", + "satori": "^0.29.0", + "satori-html": "^0.3.2", "vega": "^5.30.0", "vega-lite": "^5.21.0" } diff --git a/src/components/VegaChart.astro b/src/components/VegaChart.astro index f3471c5..bc49c31 100644 --- a/src/components/VegaChart.astro +++ b/src/components/VegaChart.astro @@ -8,8 +8,8 @@ // // The chart sits on a fixed light canvas (--chart-canvas) in both themes, so // this single baked render keeps AA contrast in light and dark. -import * as vega from 'vega'; -import { compile, type TopLevelSpec, type Config } from 'vega-lite'; +import type { TopLevelSpec } from 'vega-lite'; +import { renderChartSvg } from '../lib/renderChart'; interface Props { spec: TopLevelSpec; @@ -19,56 +19,11 @@ interface Props { const { spec, title, caption } = Astro.props; -// Chart theme — the visual parameters the renderer consumes. -const chartTheme: Config = { - font: 'IBM Plex Sans', - background: 'transparent', - view: { stroke: null }, - title: { - color: '#161616', - font: 'IBM Plex Sans', - fontSize: 13, - fontWeight: 600, - anchor: 'start', - dy: -8, - }, - axis: { - labelFont: 'IBM Plex Sans', - labelFontSize: 11, - labelColor: '#525252', - titleFont: 'IBM Plex Sans', - titleFontSize: 12, - titleFontWeight: 600, - titleColor: '#393939', - gridColor: '#e0e0e0', - domainColor: '#8d8d8d', - tickColor: '#8d8d8d', - labelPadding: 4, - }, - legend: { - labelFont: 'IBM Plex Sans', - labelFontSize: 11, - labelColor: '#525252', - titleFont: 'IBM Plex Sans', - titleFontSize: 12, - titleColor: '#393939', - symbolType: 'square', - }, - // Categorical palette (validated colorblind-safe/contrast on the - // --chart-canvas surface). - range: { - category: ['#8a3ffc', '#009d9a', '#fa4d56', '#0f62fe', '#24a148', '#d02670', '#b28600', '#1192e8'], - }, -}; - let svg = ''; let error: string | null = null; try { - const vgSpec = compile(spec, { config: chartTheme }).spec; - const view = new vega.View(vega.parse(vgSpec), { renderer: 'none' }).initialize(); - svg = await view.toSVG(); - view.finalize(); + svg = await renderChartSvg(spec); } catch (e) { error = e instanceof Error ? e.message : String(e); console.error(`[VegaChart] failed to render "${title}": ${error}`); diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index dd3bca0..f4167ac 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -10,10 +10,15 @@ import { href } from '../lib/url'; interface Props { title: string; description?: string; + /** Absolute OG image URL; falls back to the brand card. */ + image?: string; } const { title, description = 'A catalogue of data-visualization sins — the deceptive chart, why it misleads, and the honest fix.' } = Astro.props; + +const canonical = new URL(Astro.url.pathname, Astro.site).toString(); +const image = Astro.props.image ?? new URL(href('og/default.png'), Astro.site).toString(); --- @@ -22,6 +27,22 @@ const { title, description = 'A catalogue of data-visualization sins — the dec + + + + + + + + + + + + + + + + {title} diff --git a/src/lib/og.ts b/src/lib/og.ts new file mode 100644 index 0000000..11381d5 --- /dev/null +++ b/src/lib/og.ts @@ -0,0 +1,126 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import satori from 'satori'; +import { html } from 'satori-html'; +import { Resvg } from '@resvg/resvg-js'; +import type { TopLevelSpec } from 'vega-lite'; +import { renderChartSvg } from './renderChart'; +import { getReference } from './references'; + +// OpenGraph card generator. Runs only at build time. Composes a 1200×630 card +// with satori (text → vector paths, so the raster step needs no fonts) and +// rasterizes to PNG with resvg. Charts are rendered by the shared Vega pipeline, +// rasterized to PNG (resvg, using the bundled IBM Plex TTFs for axis text), and +// embedded as images. + +const OG_W = 1200; +const OG_H = 630; + +// Resolve from the project root so it works after the module is bundled into +// dist/chunks/ at build time. +const fontDir = path.join(process.cwd(), 'src/og/fonts'); +const fontPath = (f: string) => path.join(fontDir, f); +const fontFiles = [ + fontPath('IBMPlexSans-Light.ttf'), + fontPath('IBMPlexSans-Regular.ttf'), + fontPath('IBMPlexSans-SemiBold.ttf'), +]; +const fonts = [ + { name: 'IBM Plex Sans', data: fs.readFileSync(fontFiles[0]), weight: 300 as const, style: 'normal' as const }, + { name: 'IBM Plex Sans', data: fs.readFileSync(fontFiles[1]), weight: 400 as const, style: 'normal' as const }, + { name: 'IBM Plex Sans', data: fs.readFileSync(fontFiles[2]), weight: 600 as const, style: 'normal' as const }, +]; + +const esc = (s: string) => + s.replace(/&/g, '&').replace(//g, '>'); + +/** Surname of the first author, for a compact "cited: Tufte, Cairo" line. */ +function shortAuthor(key: string): string { + const first = getReference(key).authors.split(/ & |,|;/)[0].trim(); + const parts = first.split(' '); + return parts[parts.length - 1]; +} + +/** Render a chart spec to a PNG data URI at a fixed display height (2× for crispness). + * Fixing height (not width) keeps tall vconcat charts from overflowing the card. */ +async function chartImage(spec: TopLevelSpec, displayHeight: number) { + const svg = await renderChartSvg(spec); + const resvg = new Resvg(svg, { + fitTo: { mode: 'height', value: displayHeight * 2 }, + font: { fontFiles, loadSystemFonts: false, defaultFontFamily: 'IBM Plex Sans' }, + background: '#f4f4f4', + }); + const rendered = resvg.render(); + const png = rendered.asPng(); + return { + uri: `data:image/png;base64,${png.toString('base64')}`, + width: Math.round(rendered.width / 2), + height: displayHeight, + }; +} + +async function toPng(markupStr: string): Promise { + const svg = await satori(html(markupStr.trim()), { width: OG_W, height: OG_H, fonts }); + return Buffer.from(new Resvg(svg, { font: { fontFiles, loadSystemFonts: false } }).render().asPng()); +} + +function meter(severity: number): string { + // Each square needs its own `display` — satori rejects a flex row of empty divs otherwise. + return Array.from({ length: 5 }, (_, i) => + `
`, + ).join(''); +} + +const wordmark = `
CHART SINS
`; + +export interface SinCard { + poke: string; + category: string; + severity: number; + badSpec: TopLevelSpec; + fixedSpec: TopLevelSpec; + citationKeys: string[]; +} + +export async function renderSinCard(d: SinCard): Promise { + const bad = await chartImage(d.badSpec, 250); + const fixed = await chartImage(d.fixedSpec, 250); + const cited = d.citationKeys.slice(0, 3).map(shortAuthor).join(', '); + + const panel = (label: string, color: string, img: { uri: string; width: number; height: number }) => ` +
+
${label}
+ +
`; + + return toPng(` +
+
+ ${wordmark} +
${meter(d.severity)}
+
+
${esc(d.poke)}
+
+ ${panel('THE SIN', '#fa4d56', bad)} + ${panel('THE FIX', '#42be65', fixed)} +
+
+
${esc(d.category)}
+
${cited ? 'cited: ' + esc(cited) : ''}
+
+
+ `); +} + +export async function renderDefaultCard(): Promise { + return toPng(` +
+ ${wordmark} +
+
Charts that lie, and the honest fix.
+
The deceptive chart, why it fools the eye, and the honest version — with citations. Send the link instead of re-explaining.
+
+
a catalogue of data-visualization sins
+
+ `); +} diff --git a/src/lib/renderChart.ts b/src/lib/renderChart.ts new file mode 100644 index 0000000..595d62c --- /dev/null +++ b/src/lib/renderChart.ts @@ -0,0 +1,55 @@ +import * as vega from 'vega'; +import { compile, type TopLevelSpec, type Config } from 'vega-lite'; + +// Chart theme — the visual parameters the renderer consumes. Shared by the +// on-page VegaChart component and the build-time OpenGraph card generator, so +// both render charts identically. +export const chartTheme: Config = { + font: 'IBM Plex Sans', + background: 'transparent', + view: { stroke: null }, + title: { + color: '#161616', + font: 'IBM Plex Sans', + fontSize: 13, + fontWeight: 600, + anchor: 'start', + dy: -8, + }, + axis: { + labelFont: 'IBM Plex Sans', + labelFontSize: 11, + labelColor: '#525252', + titleFont: 'IBM Plex Sans', + titleFontSize: 12, + titleFontWeight: 600, + titleColor: '#393939', + gridColor: '#e0e0e0', + domainColor: '#8d8d8d', + tickColor: '#8d8d8d', + labelPadding: 4, + }, + legend: { + labelFont: 'IBM Plex Sans', + labelFontSize: 11, + labelColor: '#525252', + titleFont: 'IBM Plex Sans', + titleFontSize: 12, + titleColor: '#393939', + symbolType: 'square', + }, + // Categorical palette (validated colorblind-safe/contrast on the + // --chart-canvas surface). + range: { + category: ['#8a3ffc', '#009d9a', '#fa4d56', '#0f62fe', '#24a148', '#d02670', '#b28600', '#1192e8'], + }, +}; + +/** Compile a Vega-Lite spec and render it headlessly to a static SVG string. */ +export async function renderChartSvg(spec: TopLevelSpec): Promise { + const vgSpec = compile(spec, { config: chartTheme }).spec; + const view = new vega.View(vega.parse(vgSpec), { renderer: 'none' }).initialize(); + const svg = await view.toSVG(); + view.finalize(); + return svg; +} diff --git a/src/og/fonts/IBMPlexSans-Light.ttf b/src/og/fonts/IBMPlexSans-Light.ttf new file mode 100644 index 0000000..56e7db7 Binary files /dev/null and b/src/og/fonts/IBMPlexSans-Light.ttf differ diff --git a/src/og/fonts/IBMPlexSans-Regular.ttf b/src/og/fonts/IBMPlexSans-Regular.ttf new file mode 100644 index 0000000..5387ad4 Binary files /dev/null and b/src/og/fonts/IBMPlexSans-Regular.ttf differ diff --git a/src/og/fonts/IBMPlexSans-SemiBold.ttf b/src/og/fonts/IBMPlexSans-SemiBold.ttf new file mode 100644 index 0000000..a63f1c5 Binary files /dev/null and b/src/og/fonts/IBMPlexSans-SemiBold.ttf differ diff --git a/src/pages/og/[slug].png.ts b/src/pages/og/[slug].png.ts new file mode 100644 index 0000000..359fe8f --- /dev/null +++ b/src/pages/og/[slug].png.ts @@ -0,0 +1,24 @@ +import type { APIRoute, GetStaticPaths } from 'astro'; +import { getCollection } from 'astro:content'; +import { getChartSpec } from '../../lib/charts'; +import { renderSinCard } from '../../lib/og'; + +export const getStaticPaths: GetStaticPaths = async () => { + const sins = await getCollection('sins', ({ data }) => !data.draft); + return sins.map((sin) => ({ params: { slug: sin.id }, props: { sin } })); +}; + +export const GET: APIRoute = async ({ props }) => { + const { sin } = props as { sin: Awaited>[number] }; + const png = await renderSinCard({ + poke: sin.data.poke, + category: sin.data.category, + severity: sin.data.severity, + badSpec: getChartSpec(sin.data.badChart), + fixedSpec: getChartSpec(sin.data.fixedChart), + citationKeys: sin.data.citations.map((c) => c.key), + }); + return new Response(new Uint8Array(png), { + headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=31536000, immutable' }, + }); +}; diff --git a/src/pages/og/default.png.ts b/src/pages/og/default.png.ts new file mode 100644 index 0000000..09c96aa --- /dev/null +++ b/src/pages/og/default.png.ts @@ -0,0 +1,9 @@ +import type { APIRoute } from 'astro'; +import { renderDefaultCard } from '../../lib/og'; + +export const GET: APIRoute = async () => { + const png = await renderDefaultCard(); + return new Response(new Uint8Array(png), { + headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=31536000, immutable' }, + }); +}; diff --git a/src/pages/sins/[...slug].astro b/src/pages/sins/[...slug].astro index d0549f2..51e8018 100644 --- a/src/pages/sins/[...slug].astro +++ b/src/pages/sins/[...slug].astro @@ -23,9 +23,10 @@ const fixedSpec = getChartSpec(sin.data.fixedChart); // Canonical, shareable URL for this sin — the thing you paste into a thread. const pageUrl = new URL(href(`sins/${sin.id}/`), Astro.site).toString(); +const ogImage = new URL(href(`og/${sin.id}.png`), Astro.site).toString(); --- - +