SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
5.1 KB · 107 lines tsx
Raw Blame History
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