150 lines
6.1 KiB
TypeScript
150 lines
6.1 KiB
TypeScript
// Загрузка данных 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<string, string>;
|
|
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<void> {
|
|
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<OsmElement[]> {
|
|
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<GeocodeResult[]> {
|
|
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,
|
|
}));
|
|
}
|