spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1import type { ReactNode } from 'react';2import { MARK_PATHS } from '@/components/brand/Logo';3import { MAP_HEIGHT, MAP_WIDTH, worldPaths } from '@/lib/map-geo';45/**6 * Shared building blocks for the OG/Twitter images (next/og ImageResponse → Satori). Satori supports a flex7 * subset of CSS and inline SVG; no CSS variables, so colours are literal. Dark editorial background, the brand8 * mark, a faint world map (Equal Earth, world-atlas 110m) as the "wallpaper" motif. Fonts: Satori's bundled9 * default sans (loading Google fonts at build would make the build network-dependent — avoided on purpose).10 */11export const OG_BG = '#151513';12export const OG_INK = '#f2f0ea';13export const OG_INK2 = '#c9c6bd';14export const OG_INK3 = '#8a877f';15export const OG_ACCENT = '#5598e7';16export const OG_RULE = '#2c2b28';17export const OG_LAND = '#26364a';18export const OG_LAND_STROKE = '#151513';1920export function OgMark({ size = 96, color = OG_ACCENT }: { size?: number; color?: string }) {21 const m = MARK_PATHS;22 return (23 <svg width={size} height={size} viewBox="0 0 32 32" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">24 <circle cx={m.ring.cx} cy={m.ring.cy} r={m.ring.r} />25 <ellipse cx={m.meridian.cx} cy={m.meridian.cy} rx={m.meridian.rx} ry={m.meridian.ry} strokeWidth={1.2} opacity="0.5" />26 <path d={m.tropic} strokeWidth={1.2} opacity="0.5" />27 <path d={m.equator} />28 <path d={m.legs} />29 </svg>30 );31}3233/** Large, faint globe with meridians and parallels, positioned at the right edge (kept for the entity cards). */34export function OgMeridians({ size = 760, x = 640, y = -80 }: { size?: number; x?: number; y?: number }) {35 return (36 <svg width={size} height={size} viewBox="0 0 200 200" fill="none" stroke={OG_ACCENT} strokeWidth={0.6} style={{ position: 'absolute', left: x, top: y, opacity: 0.35 }}>37 <circle cx="100" cy="100" r="96" />38 <ellipse cx="100" cy="100" rx="64" ry="96" />39 <ellipse cx="100" cy="100" rx="32" ry="96" />40 <line x1="100" y1="4" x2="100" y2="196" />41 <line x1="4" y1="100" x2="196" y2="100" />42 <ellipse cx="100" cy="100" rx="96" ry="48" />43 <ellipse cx="100" cy="100" rx="96" ry="80" />44 <path d="M12 60h176M12 140h176" opacity="0.7" />45 </svg>46 );47}4849/**50 * Faint world map wallpaper: every country of the 110m atlas as a filled path (Equal Earth), plus the sphere51 * outline and a light graticule. `width` in px; positioned absolutely by the caller through `style`.52 * `highlight` (ISO3 list) fills those countries in the accent colour.53 */54export function OgWorldMap({ width = 1320, style, opacity = 0.9, highlight = [] }: { width?: number; style?: React.CSSProperties; opacity?: number; highlight?: string[] }) {55 const { paths, sphere } = worldPaths();56 const height = Math.round((width * MAP_HEIGHT) / MAP_WIDTH);57 const hl = new Set(highlight);58 return (59 <svg width={width} height={height} viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`} style={{ position: 'absolute', opacity, ...style }}>60 <path d={sphere} fill="#191a1c" stroke={OG_RULE} strokeWidth={1} />61 {[-60, -30, 0, 30, 60].map((lat) => {62 const y = MAP_HEIGHT / 2 - (lat / 90) * (MAP_HEIGHT / 2) * 0.98;63 return <line key={lat} x1={0} x2={MAP_WIDTH} y1={y} y2={y} stroke={OG_RULE} strokeWidth={0.6} />;64 })}65 {paths.map((p, i) => (66 <path key={p.iso3 ?? i} d={p.d} fill={p.iso3 && hl.has(p.iso3) ? OG_ACCENT : OG_LAND} stroke={OG_LAND_STROKE} strokeWidth={0.5} />67 ))}68 </svg>69 );70}7172export function OgFrame({ children, map = false, highlight }: { children: ReactNode; map?: boolean; highlight?: string[] }) {73 return (74 <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: OG_BG, color: OG_INK, position: 'relative', overflow: 'hidden', padding: 64, fontFamily: 'sans-serif' }}>75 {map ? <OgWorldMap width={1320} style={{ left: -60, top: 40 }} opacity={0.55} highlight={highlight} /> : <OgMeridians />}76 <div style={{ position: 'absolute', left: 64, right: 64, bottom: 56, height: 1, background: OG_RULE }} />77 {children}78 </div>79 );80}8182export function OgWordmark({ size = 34 }: { size?: number }) {83 return (84 <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>85 <OgMark size={size * 1.35} />86 <div style={{ display: 'flex', fontSize: size, letterSpacing: -0.5 }}>87 <span style={{ fontWeight: 400 }}>Country</span>88 <span style={{ fontWeight: 700 }}>Atlas</span>89 </div>90 </div>91 );92}9394/** Row of three or four headline figures: label above, big number below (used by the default share card). */95export function OgFigures({ items }: { items: Array<{ label: string; value: string }> }) {96 return (97 <div style={{ display: 'flex', gap: 56 }}>98 {items.map((it) => (99 <div key={it.label} style={{ display: 'flex', flexDirection: 'column' }}>100 <div style={{ fontSize: 18, color: OG_INK3, textTransform: 'uppercase', letterSpacing: 1.6, display: 'flex' }}>{it.label}</div>101 <div style={{ fontSize: 44, fontWeight: 600, marginTop: 6, display: 'flex' }}>{it.value}</div>102 </div>103 ))}104 </div>105 );106}107