Initial commit: browser OSM city generator

This commit is contained in:
mrleo1nid committed 2026-10-30 18:10:47 +03:00
commit 2d4bad0dc7
32 files changed
+5915

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
// Загрузка данных 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://overpass-api.de/api/interpreter',
'https://overpass.private.coffee/api/interpreter',
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
];
export function buildQuery(b: BBox): string {
const bb = `(${b[0]},${b[1]},${b[2]},${b[3]})`;
return `[out:json][timeout:120];
(
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};
);
out geom;`;
}
export async function fetchOverpass(b: BBox, onStatus?: (s: string) => void, signal?: AbortSignal): Promise<OsmElement[]> {
const query = buildQuery(b);
let lastErr: unknown = null;
for (const url of OVERPASS_ENDPOINTS) {
for (let attempt = 0; attempt < 2; attempt++) {
try {
onStatus?.(`Запрос к ${new URL(url).host}${attempt ? ' (повтор)' : ''}…`);
const res = await fetch(url, {
method: 'POST',
body: 'data=' + encodeURIComponent(query),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
signal,
});
if (res.status === 429 || res.status === 504) {
lastErr = new Error(`Сервер перегружен (${res.status})`);
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
continue;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (json.remark && /error|timed out|runtime/i.test(json.remark)) throw new Error(json.remark);
return json.elements as OsmElement[];
} catch (e) {
if (signal?.aborted) throw e;
lastErr = e;
break;
}
}
}
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,
}));
}