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();
---
-
+