// Загрузка данных OSM через Overpass API (вызывается прямо из браузера, CORS разрешён). // Для публичной игры с множеством игроков нужно своё зеркало или статическая нарезка — // публичные серверы Overpass рассчитаны на умеренную нагрузку. export type BBox = [number, number, number, number]; // south, west, north, east export interface OsmElement { type: 'node' | 'way' | 'relation'; id: number; tags?: Record; lat?: number; lon?: number; geometry?: { lat: number; lon: number }[]; members?: { type: string; ref: number; role: string; geometry?: { lat: number; lon: number }[] }[]; } export const OVERPASS_ENDPOINTS = [ 'https://maps.mail.ru/osm/tools/overpass/api/interpreter', 'https://overpass-api.de/api/interpreter', 'https://overpass.private.coffee/api/interpreter', ]; const REQUEST_TIMEOUT_MS = 45_000; const SERVER_REQUEST_TIMEOUT_MS = 90_000; function waitForRetry(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const cancel = () => { clearTimeout(timer); signal?.removeEventListener('abort', cancel); reject(new DOMException('Отменено', 'AbortError')); }; const timer = setTimeout(() => { signal?.removeEventListener('abort', cancel); resolve(); }, ms); signal?.addEventListener('abort', cancel, { once: true }); if (signal?.aborted) cancel(); }); } export function buildQuery(b: BBox): string { const bb = `(${b[0]},${b[1]},${b[2]},${b[3]})`; return `[out:json][timeout:30]; ( way["building"]${bb}; relation["building"]["type"="multipolygon"]${bb}; way["highway"]${bb}; way["railway"~"^(rail|tram|light_rail|narrow_gauge|subway|platform)$"]${bb}; way["waterway"]${bb}; way["landuse"]${bb}; relation["landuse"]${bb}; way["natural"]${bb}; relation["natural"]${bb}; way["leisure"]${bb}; relation["leisure"]${bb}; way["amenity"]${bb}; relation["amenity"]${bb}; way["place"~"square"]${bb}; way["area:highway"]${bb}; node["natural"="tree"]${bb}; node["shop"]${bb}; node["amenity"]${bb}; node["office"]${bb}; node["craft"]${bb}; node["tourism"]${bb}; node["healthcare"]${bb}; node["highway"="street_lamp"]${bb}; node["highway"="bus_stop"]${bb}; node["highway"="crossing"]${bb}; node["public_transport"="platform"]${bb}; ); out geom;`; } export async function fetchOverpass(b: BBox, onStatus?: (s: string) => void, signal?: AbortSignal): Promise { const server = typeof window === 'undefined'; const timeout = server ? SERVER_REQUEST_TIMEOUT_MS : REQUEST_TIMEOUT_MS; const query = server ? buildQuery(b).replace('[timeout:30]', '[timeout:60]') : buildQuery(b); let lastErr: unknown = null; for (const url of OVERPASS_ENDPOINTS) { for (let attempt = 0; attempt < 2; attempt++) { if (signal?.aborted) throw new DOMException('Отменено', 'AbortError'); const controller = new AbortController(); const cancel = () => controller.abort(); signal?.addEventListener('abort', cancel, { once: true }); let timedOut = false; const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeout); try { onStatus?.(`Запрос к ${new URL(url).host}${attempt ? ' (повтор)' : ''} — до ${timeout / 1000} секунд…`); const res = await fetch(url, { method: 'POST', body: 'data=' + encodeURIComponent(query), headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(server ? { 'User-Agent': 'citygen/0.2.0 (self-hosted OSM city generator)' } : {}) }, signal: controller.signal, }); if (res.status === 429 || res.status === 504) { lastErr = new Error(`Overpass ${new URL(url).host} временно отклонил запрос (${res.status})`); clearTimeout(timer); if (attempt === 0) { const retry = Number(res.headers?.get('Retry-After')); const delay = Number.isFinite(retry) && retry > 0 ? Math.min(30_000, retry * 1000) : 3000; onStatus?.(`Overpass вернул ${res.status}. Повтор через ${Math.ceil(delay / 1000)} с…`); await waitForRetry(delay, signal); } continue; } if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); if (signal?.aborted) throw new DOMException('Отменено', 'AbortError'); if (json.remark && /error|timed out|runtime/i.test(json.remark)) throw new Error(json.remark); if (!Array.isArray(json.elements)) throw new Error('Сервер вернул ответ без списка elements'); return json.elements as OsmElement[]; } catch (e) { if (signal?.aborted) throw new DOMException('Отменено', 'AbortError'); lastErr = timedOut ? new Error(`Таймаут ${new URL(url).host}: ${timeout / 1000} секунд`) : e; break; } finally { clearTimeout(timer); signal?.removeEventListener('abort', cancel); } } } throw new Error('Не удалось загрузить данные OSM: ' + (lastErr instanceof Error ? lastErr.message : String(lastErr))); } export interface GeocodeResult { name: string; lat: number; lon: number; bbox: BBox; } /** Поиск места через Nominatim (не чаще раза в секунду по правилам сервиса). */ export async function geocode(q: string): Promise { const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=6&accept-language=ru&q=${encodeURIComponent(q)}`; const res = await fetch(url, { headers: { Accept: 'application/json' } }); if (!res.ok) throw new Error('Поиск недоступен: HTTP ' + res.status); const arr = await res.json(); return arr.map((r: any) => ({ name: r.display_name, lat: +r.lat, lon: +r.lon, bbox: [+r.boundingbox[0], +r.boundingbox[2], +r.boundingbox[1], +r.boundingbox[3]] as BBox, })); }