diff --git a/index.html b/index.html
index 79e14f4..7bb6b88 100644
--- a/index.html
+++ b/index.html
@@ -7,6 +7,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
index c9a9816..c0f4c12 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"generate-icons": "node scripts/generate-pwa-icons.mjs",
+ "generate-og-image": "bun run scripts/generate-og-image.mjs",
"build": "bun run generate-icons && tsc -b && vite build",
"lint": "oxlint",
"test": "vitest run",
diff --git a/public/og-image.png b/public/og-image.png
new file mode 100644
index 0000000..178c5ee
Binary files /dev/null and b/public/og-image.png differ
diff --git a/scripts/generate-og-image.mjs b/scripts/generate-og-image.mjs
new file mode 100644
index 0000000..d518621
--- /dev/null
+++ b/scripts/generate-og-image.mjs
@@ -0,0 +1,82 @@
+import { readFileSync, writeFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import sharp from 'sharp'
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..')
+const WIDTH = 1200
+const HEIGHT = 630
+
+// The social card shows a real map from the bundled sample project
+const sample = JSON.parse(readFileSync(join(root, 'public/sample.anal'), 'utf8'))
+const [, mapBase64] = sample.mapImage.src.split(',')
+const mapPng = await sharp(Buffer.from(mapBase64, 'base64'))
+ .resize({ width: 960, withoutEnlargement: true })
+ .jpeg({ quality: 88 })
+ .toBuffer()
+const mapMeta = await sharp(mapPng).metadata()
+
+const CARD_W = 430
+const CARD_H = Math.round((CARD_W * mapMeta.height) / mapMeta.width)
+const card = { x: 732, y: (HEIGHT - CARD_H) / 2 - 6 }
+const cardCentre = { x: card.x + CARD_W / 2, y: card.y + CARD_H / 2 }
+const toCardPx = (fx, fy) => ({ x: card.x + fx * CARD_W, y: card.y + fy * CARD_H })
+
+// Orange control markers placed over the map, in fractions of the card size
+const markers = [
+ { label: 'A', fx: 0.3, fy: 0.42 },
+ { label: 'B', fx: 0.52, fy: 0.58 },
+ { label: 'C', fx: 0.74, fy: 0.36 },
+]
+
+const logo = readFileSync(join(root, 'public/favicon.svg')).toString('base64')
+const mapDataUrl = `data:image/jpeg;base64,${mapPng.toString('base64')}`
+
+const markerSvg = markers
+ .map(({ label, fx, fy }) => {
+ const p = toCardPx(fx, fy)
+ return `
+
+ ${label}`
+ })
+ .join('')
+
+const svg = `
+`
+
+const png = await sharp(Buffer.from(svg)).png().toBuffer()
+writeFileSync(join(root, 'public/og-image.png'), png)
+console.log(`Generated og-image.png (${WIDTH}x${HEIGHT})`)