HistoryPrint берёт любой город и говорит, какая часть человеческой истории случилась в его радиусе. ~13 000 событий за 5 000 лет, 12 категорий (войны, революции, пандемии, открытия), скоринг по экспоненциальному убыванию расстояния, и в финале — один из 20 архетипов: «Born in Fire», «Plague Walker», «Heir of Enlightenment».

Стек — Next.js 14 App Router, Mapbox GL JS в проекции глобуса, Tailwind, Vercel Functions + Vercel KV для лидерборда. Никакого ML, чистая математика.
В этом посте — три неочевидные вещи, которые в этом проекте оказались интересными:
Как разруливать многорегиональные события (Чёрная смерть достигла Флоренции в 1348-м, Москвы — в 1353-м: оба должны быть «локально верными» в зависимости от запрашивающего города).
Как защитить публичный лидерборд от curl’а одной HMAC-подписью без аутентификации.
Как генерить per-city OG-картинки на @vercel/og + кешировать на CDN.
Формула скоринга
Для каждого события рядом с пользователем вклад считается так:
score = (significance / 100)² × exp(−distance / decay) × era_weight × category_weight × 1000
decay = 500 км для войн (битвы — точечные события, далеко не радиируют), 1500 км для всего остального (революции, пандемии, технологии распространяются волнами).
era_weight даёт +вес древним событиям: 2.0 ancient → 0.8 contemporary. Это компенсирует bias датасета: про новое истории вообще пишут больше.
category_weight отражает «исторический радиус» категории: pandemic = 3.0, genocide = 2.5, технология = 1.8, война = 0.3 (т.к. битв много и они локальны — иначе они бы доминировали везде).
Multi-regional events
Чёрная смерть в датасете — одна запись, но Москва увидела её в 1353-м, а Флоренция — в 1348-м. Если просто хранить главную точку (Бухарест 1347), то любой запрос будет отдавать неправильный год.
Решение — таблица overrides по id события:
export const AFFECTED_CITIES: Record<number, AffectedCity[]> = { // Black Death (id=5) 5: [ { city: "Caffa", lat: 45.0316, lng: 35.3826, year: 1346 }, { city: "Messina", lat: 38.1938, lng: 15.5540, year: 1347 }, { city: "Florence", lat: 43.7696, lng: 11.2558, year: 1348 }, { city: "Moscow", lat: 55.7558, lng: 37.6173, year: 1353 }, // …18 cities total ], };
Скоринг при расчёте выбирает ближайший к пользователю кандидат из (главная точка + overrides), и берёт его дату:
function resolveLocalLocation(event, userLat, userLng) { const overrides = AFFECTED_CITIES[event.id]; const candidates = [{ lat: event.lat, lng: event.lng, year: event.year }]; if (overrides) candidates.push(...overrides); // Haversine + min-by-distance return candidates.reduce((best, c) => { const d = haversine(userLat, userLng, c.lat, c.lng); return d < best.dist ? { ...c, dist: d } : best; }, { dist: Infinity }); }
Аналогично — Spanish Flu (12 городов), Holocaust (~20 site’ов), Industrial Revolution (16 центров). Покрытие точечное — нет смысла размечать каждое событие, важно только те, где разные регионы испытали его в существенно разные годы.
Архетипы
Категориальная структура события + временное распределение → один из 20 архетипов: «Plague Walker», «Heir of Enlightenment», «Crossroads», «Born Under Empires», «Beyond History’s Reach» и т.д.

Алгоритм:
Для каждой категории берём топ-15 событий, взвешиваем по rank-decay (signal_i = score_i × 0.85^i).
Делим signal на log2(count + 1) — rarity dampening, чтобы редкие категории не получали несправедливо большой вес просто потому, что в датасете их мало.
Если доля топ-категории ≥ 15% и она в 1.2× больше второй — выдаём связанный архетип («pandemic-доминирующий → Plague Walker»).
Иначе fallback по эпохе: ≥35% ancient → Old World, ≥35% medieval → Heart of the Middle Ages, ≥45% contemporary → Witness to Modernity.
Иначе — Crossroads.
Decay для архетипного скоринга — 300 км (резче, чем 1500 км для общего score), потому что архетип отражает характер места, а не общее историческое значение.
Anti-tampering на лидерборде
Лидерборд публичный. Без защиты curl-savvy посетитель может POST’ить в /api/leaderboard любой score и сесть на #1.
Решение — HMAC-токен, который выдаёт /api/score:
// /api/score возвращает в ответе const signed = signLeaderboardToken({ lat, lng, score: result.totalScore, archetype: result.archetype.name, }); return NextResponse.json({ ...result, leaderboardToken: signed.token }); // /api/leaderboard валидирует const ok = verifyLeaderboardToken(token, { lat, lng, score, archetype }); if (!ok.ok) return NextResponse.json({ error: "Invalid token" }, { status: 401 });
Внутри — каноничный JSON {lat, lng, score, archetype, exp} подписан HMAC-SHA256 на серверном секрете, TTL 1ч, проверка через timingSafeEqual. Любой tweak body инвалидирует подпись.
Никакой аутентификации пользователя нет — нам не нужно знать кто ты, только проверить, что score реально вышел из нашего скоринга. Бывает достаточно.
Per-city OG-картинки
Каждый город — это /c/ и шерится в X/Telegram. На каждый шер нужна своя OG-картинка с скором, архетипом и breakdown’ом — иначе шер выглядит обезличенно.
Решение — app/c/[slug]/opengraph-image.tsx на runtime: nodejs (потому что импортируется 4МБ датасет, edge не подойдёт) с revalidate: 86400 — кешируется на 24 часа на CDN, каждый последующий социальный краулер получает кеш.
Был баг: на Windows + Node 24 дефолтный шрифтовой loader @vercel/og падает с path-handling ошибкой. Лечится явной передачей шрифтов:
const fonts = await Promise.all([ fetch("https://cdn.jsdelivr.net/fontsource/fonts/cinzel@latest/latin-700-normal.ttf"), fetch("https://cdn.jsdelivr.net/fontsource/fonts/crimson-pro@latest/latin-400-normal.ttf"), ].map(r => r.then(r => r.arrayBuffer()))); return new ImageResponse(<Card />, { ...size, fonts });
Кеш module-level let cachedFonts спасает от повторных fetch’ей при тёплых reuse’ах функции.
Open issues — куда зову помочь
Покрытие данных вне Europe / NA / ME / EA тонкое. Если у вас есть нормализованные lat/lng + год для региональных событий (Африка южнее Сахары, Океания, Центральная/Южная Америка) — велкам в репорт-кнопку в drawer’е каждого события или PR.
Категоризация и значимость субъективны. Аргументированные правки приветствуются.
Ссылки
Если думаешь «а у меня в Иркутске наверное всё пусто» — попробуй. Бывают сюрпризы.
