mirror of
https://github.com/olehomelchenko/chart-sins.git
synced 2026-08-08 02:22:43 +00:00
Add OpenGraph/Twitter cards, generated per sin at build time
Link previews are the funnel for a "send the link" site, so give every sin a share-optimized card. - og/twitter meta tags on every page (poke as description, canonical URL, summary_large_image) - Per-sin 1200x630 card generated at build: the poke, the before/after charts (uniform height so tall vconcat charts fit), severity meter, category, and short citations — composed with satori, rasterized with @resvg/resvg-js, no browser needed - Default brand card for the homepage and canon - Shared chart pipeline extracted to lib/renderChart.ts (used by both the on-page component and the card generator) - Bundle three IBM Plex TTF weights for the rasterizer Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPEvmMbac2fCvKXpQcovj8
This commit is contained in:
@@ -14,7 +14,10 @@
|
|||||||
"@astrojs/mdx": "^4.2.0",
|
"@astrojs/mdx": "^4.2.0",
|
||||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||||
"@fontsource/ibm-plex-sans": "^5.3.0",
|
"@fontsource/ibm-plex-sans": "^5.3.0",
|
||||||
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"astro": "^5.6.0",
|
"astro": "^5.6.0",
|
||||||
|
"satori": "^0.29.0",
|
||||||
|
"satori-html": "^0.3.2",
|
||||||
"vega": "^5.30.0",
|
"vega": "^5.30.0",
|
||||||
"vega-lite": "^5.21.0"
|
"vega-lite": "^5.21.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
//
|
//
|
||||||
// The chart sits on a fixed light canvas (--chart-canvas) in both themes, so
|
// 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.
|
// this single baked render keeps AA contrast in light and dark.
|
||||||
import * as vega from 'vega';
|
import type { TopLevelSpec } from 'vega-lite';
|
||||||
import { compile, type TopLevelSpec, type Config } from 'vega-lite';
|
import { renderChartSvg } from '../lib/renderChart';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
spec: TopLevelSpec;
|
spec: TopLevelSpec;
|
||||||
@@ -19,56 +19,11 @@ interface Props {
|
|||||||
|
|
||||||
const { spec, title, caption } = Astro.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 svg = '';
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const vgSpec = compile(spec, { config: chartTheme }).spec;
|
svg = await renderChartSvg(spec);
|
||||||
const view = new vega.View(vega.parse(vgSpec), { renderer: 'none' }).initialize();
|
|
||||||
svg = await view.toSVG();
|
|
||||||
view.finalize();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : String(e);
|
error = e instanceof Error ? e.message : String(e);
|
||||||
console.error(`[VegaChart] failed to render "${title}": ${error}`);
|
console.error(`[VegaChart] failed to render "${title}": ${error}`);
|
||||||
|
|||||||
@@ -10,10 +10,15 @@ import { href } from '../lib/url';
|
|||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
description?: 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.' } =
|
const { title, description = 'A catalogue of data-visualization sins — the deceptive chart, why it misleads, and the honest fix.' } =
|
||||||
Astro.props;
|
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();
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -22,6 +27,22 @@ const { title, description = 'A catalogue of data-visualization sins — the dec
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
|
<link rel="canonical" href={canonical} />
|
||||||
|
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="Chart Sins" />
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:url" content={canonical} />
|
||||||
|
<meta property="og:image" content={image} />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={title} />
|
||||||
|
<meta name="twitter:description" content={description} />
|
||||||
|
<meta name="twitter:image" content={image} />
|
||||||
|
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+126
@@ -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, '<').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<Buffer> {
|
||||||
|
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) =>
|
||||||
|
`<div style="display:flex;width:20px;height:20px;background:${i < severity ? '#fa4d56' : '#525252'}"></div>`,
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordmark = `<div style="display:flex;font-size:22px;font-weight:600;letter-spacing:4px;color:#f4f4f4">CHART SINS</div>`;
|
||||||
|
|
||||||
|
export interface SinCard {
|
||||||
|
poke: string;
|
||||||
|
category: string;
|
||||||
|
severity: number;
|
||||||
|
badSpec: TopLevelSpec;
|
||||||
|
fixedSpec: TopLevelSpec;
|
||||||
|
citationKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderSinCard(d: SinCard): Promise<Buffer> {
|
||||||
|
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 }) => `
|
||||||
|
<div style="display:flex;flex-direction:column;background:#f4f4f4;padding:12px;border-top:5px solid ${color}">
|
||||||
|
<div style="display:flex;font-size:13px;font-weight:600;letter-spacing:1px;color:${color};margin-bottom:6px">${label}</div>
|
||||||
|
<img src="${img.uri}" style="width:${img.width}px;height:${img.height}px" />
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return toPng(`
|
||||||
|
<div style="display:flex;flex-direction:column;width:${OG_W}px;height:${OG_H}px;background:#161616;padding:44px 56px">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||||
|
${wordmark}
|
||||||
|
<div style="display:flex;gap:6px">${meter(d.severity)}</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;font-size:40px;font-weight:300;line-height:1.15;color:#f4f4f4;margin-top:22px;max-width:1090px">${esc(d.poke)}</div>
|
||||||
|
<div style="display:flex;gap:22px;margin-top:26px;justify-content:center">
|
||||||
|
${panel('THE SIN', '#fa4d56', bad)}
|
||||||
|
${panel('THE FIX', '#42be65', fixed)}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:space-between;margin-top:22px;font-size:17px;color:#a8a8a8">
|
||||||
|
<div style="display:flex">${esc(d.category)}</div>
|
||||||
|
<div style="display:flex">${cited ? 'cited: ' + esc(cited) : ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderDefaultCard(): Promise<Buffer> {
|
||||||
|
return toPng(`
|
||||||
|
<div style="display:flex;flex-direction:column;justify-content:space-between;width:${OG_W}px;height:${OG_H}px;background:#161616;padding:64px 72px">
|
||||||
|
${wordmark}
|
||||||
|
<div style="display:flex;flex-direction:column">
|
||||||
|
<div style="display:flex;font-size:66px;font-weight:300;line-height:1.08;color:#f4f4f4;max-width:1000px">Charts that lie, and the honest fix.</div>
|
||||||
|
<div style="display:flex;font-size:26px;color:#a8a8a8;margin-top:24px;max-width:900px">The deceptive chart, why it fools the eye, and the honest version — with citations. Send the link instead of re-explaining.</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;font-size:18px;color:#6f6f6f">a catalogue of data-visualization sins</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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<ReturnType<typeof getCollection>>[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' },
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -23,9 +23,10 @@ const fixedSpec = getChartSpec(sin.data.fixedChart);
|
|||||||
|
|
||||||
// Canonical, shareable URL for this sin — the thing you paste into a thread.
|
// 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 pageUrl = new URL(href(`sins/${sin.id}/`), Astro.site).toString();
|
||||||
|
const ogImage = new URL(href(`og/${sin.id}.png`), Astro.site).toString();
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={`${sin.data.title} — Chart Sins`} description={sin.data.poke}>
|
<BaseLayout title={`${sin.data.title} — Chart Sins`} description={sin.data.poke} image={ogImage}>
|
||||||
<article class="wrap sin">
|
<article class="wrap sin">
|
||||||
<nav class="breadcrumb"><a href={href()}>All sins</a> / {sin.data.title}</nav>
|
<nav class="breadcrumb"><a href={href()}>All sins</a> / {sin.data.title}</nav>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user