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

+237
View File
@@ -0,0 +1,237 @@
import { AreaPicker, bboxSizeKm } from './view/picker';
import { Viewer } from './view/viewer';
import { fetchOverpass, geocode, type BBox, type OsmElement } from './osm/overpass';
import { generateWorld } from './gen/pipeline';
import { downloadWorld, hasWorld, loadWorld, readJsonFile, saveWorld } from './store';
import type { World } from './types';
const MAX_KM2 = 6;
const WARN_KM2 = 2.5;
const app = document.getElementById('app')!;
let viewer: Viewer | null = null;
let picker: AreaPicker | null = null;
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]!));
// ------------------------------------------------------------------ прогресс
const overlay = document.createElement('div');
overlay.className = 'overlay';
overlay.hidden = true;
overlay.innerHTML = `<div class="card progress-card"><div class="p-title">Генерация</div><div class="p-text"></div><div class="bar"><i></i></div><button class="btn" data-a="cancel">Отмена</button></div>`;
document.body.appendChild(overlay);
let abort: AbortController | null = null;
overlay.querySelector('[data-a=cancel]')!.addEventListener('click', () => abort?.abort());
function progress(text: string, frac: number) {
overlay.hidden = false;
overlay.querySelector('.p-text')!.textContent = text;
(overlay.querySelector('.bar i') as HTMLElement).style.width = `${Math.round(frac * 100)}%`;
}
const hideProgress = () => (overlay.hidden = true);
// ------------------------------------------------------------------ экраны
function showViewer(world: World) {
picker = null;
app.innerHTML = '';
viewer?.destroy();
viewer = new Viewer(app, world, {
onNew: () => showPicker(world.bbox),
onExport: () => downloadWorld(world),
});
(window as any).viewer = viewer;
}
async function showPicker(initial?: BBox) {
viewer?.destroy();
viewer = null;
app.innerHTML = '';
const stage = document.createElement('div');
stage.className = 'picker-stage';
app.appendChild(stage);
const panel = document.createElement('div');
panel.className = 'picker-panel card';
panel.innerHTML = `
<div class="brand">Город сверху</div>
<p class="lead">Выделите на карте небольшой город или район — по данным OpenStreetMap будет построена подробная карта с планировками и мебелью во всех зданиях.</p>
<form class="search"><input type="search" placeholder="Найти место: город, район, улица…" autocomplete="off"><button class="btn">Найти</button></form>
<div class="results"></div>
<div class="row">
<button class="btn" data-a="select">▭ Выделить область</button>
<button class="btn ghost" data-a="quick">1×1 км в центре</button>
</div>
<div class="hint">Или перетаскивайте с зажатым Shift.</div>
<div class="sel-info">Область не выбрана</div>
<label class="field">Название <input type="text" data-f="name" placeholder="например, Балцерс"></label>
<button class="btn primary" data-a="go" disabled>Сгенерировать</button>
<details class="more"><summary>Другие способы</summary>
<div class="col">
<button class="btn ghost" data-a="last" hidden>Открыть последний мир</button>
<label class="btn ghost file">Открыть мир (.json / .json.gz)<input type="file" accept=".json,.gz" data-f="world" hidden></label>
<label class="btn ghost file">Сгенерировать из выгрузки Overpass (.json)<input type="file" accept=".json,.gz" data-f="osm" hidden></label>
<div class="hint">Выгрузка Overpass должна быть в формате <code>[out:json] … out geom;</code>. Область берётся из выделения или из данных.</div>
</div>
</details>
<div class="err" hidden></div>`;
stage.appendChild(panel);
const mapBox = document.createElement('div');
mapBox.className = 'map-box';
stage.appendChild(mapBox);
mapBox.insertAdjacentHTML('beforeend', '<div class="attribution">Карта © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors</div>');
const $ = <T extends Element>(s: string) => panel.querySelector(s) as T;
const nameInput = $<HTMLInputElement>('[data-f=name]');
const goBtn = $<HTMLButtonElement>('[data-a=go]');
const selBtn = $<HTMLButtonElement>('[data-a=select]');
const err = $<HTMLDivElement>('.err');
const showErr = (m: string | null) => { err.hidden = !m; err.textContent = m ?? ''; };
let bbox: BBox | null = null;
picker = new AreaPicker(mapBox, (b) => {
bbox = b;
const info = $<HTMLDivElement>('.sel-info');
if (!b) {
info.textContent = 'Область не выбрана';
info.className = 'sel-info';
goBtn.disabled = true;
return;
}
const s = bboxSizeKm(b);
info.textContent = `${s.w.toFixed(2)} × ${s.h.toFixed(2)} км · ${s.km2.toFixed(2)} км²` + (s.km2 > MAX_KM2 ? ' — слишком большая область' : s.km2 > WARN_KM2 ? ' — большая область, генерация займёт время' : '');
info.className = 'sel-info' + (s.km2 > MAX_KM2 ? ' bad' : s.km2 > WARN_KM2 ? ' warn' : ' ok');
goBtn.disabled = s.km2 > MAX_KM2;
});
if (initial) picker.setSelection(initial);
else picker.fit([47.06, 9.49, 47.075, 9.52]);
selBtn.addEventListener('click', () => {
picker!.selectMode = !picker!.selectMode;
selBtn.classList.toggle('on', picker!.selectMode);
});
$<HTMLButtonElement>('[data-a=quick]').addEventListener('click', () => picker!.selectAroundCenter(1));
const form = $<HTMLFormElement>('.search');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const q = (form.querySelector('input') as HTMLInputElement).value.trim();
if (!q) return;
const box = $<HTMLDivElement>('.results');
box.innerHTML = '<div class="hint">Ищу…</div>';
try {
const res = await geocode(q);
box.innerHTML = res.length ? '' : '<div class="hint">Ничего не найдено</div>';
for (const r of res) {
const b = document.createElement('button');
b.className = 'result';
b.textContent = r.name;
b.addEventListener('click', () => {
const s = bboxSizeKm(r.bbox);
picker!.fit(r.bbox);
if (s.km2 <= MAX_KM2 && s.km2 > 0.02) picker!.setSelection(r.bbox, false);
else {
picker!.fit([r.lat - 0.01, r.lon - 0.015, r.lat + 0.01, r.lon + 0.015]);
picker!.selectAroundCenter(1.2);
}
nameInput.value = r.name.split(',')[0];
box.innerHTML = '';
});
box.appendChild(b);
}
} catch (ex) {
box.innerHTML = `<div class="hint">${esc((ex as Error).message)}</div>`;
}
});
goBtn.addEventListener('click', async () => {
if (!bbox) return;
showErr(null);
abort = new AbortController();
try {
progress('Загрузка данных OpenStreetMap…', 0.02);
const els = await fetchOverpass(bbox, (s) => progress(s, 0.03), abort.signal);
await runGeneration(els, bbox, nameInput.value.trim() || 'Новая карта');
} catch (ex) {
hideProgress();
if ((ex as Error).name !== 'AbortError') showErr((ex as Error).message);
}
});
const lastBtn = $<HTMLButtonElement>('[data-a=last]');
if (await hasWorld()) lastBtn.hidden = false;
lastBtn.addEventListener('click', async () => {
progress('Загрузка сохранённого мира…', 0.5);
const w = await loadWorld();
hideProgress();
if (w) showViewer(w);
});
$<HTMLInputElement>('[data-f=world]').addEventListener('change', async (e) => {
const f = (e.target as HTMLInputElement).files?.[0];
if (!f) return;
try {
progress('Чтение файла…', 0.4);
const w = (await readJsonFile(f)) as World;
if (!w.buildings || !w.bounds) throw new Error('Это не файл мира');
hideProgress();
showViewer(w);
} catch (ex) {
hideProgress();
showErr((ex as Error).message);
}
});
$<HTMLInputElement>('[data-f=osm]').addEventListener('change', async (e) => {
const f = (e.target as HTMLInputElement).files?.[0];
if (!f) return;
try {
progress('Чтение выгрузки…', 0.03);
const data = await readJsonFile(f);
const els: OsmElement[] = data.elements;
if (!Array.isArray(els)) throw new Error('В файле нет поля elements');
const b = bbox ?? bboxOfElements(els);
abort = new AbortController();
await runGeneration(els, b, nameInput.value.trim() || f.name.replace(/\.json.*$/, ''));
} catch (ex) {
hideProgress();
showErr((ex as Error).message);
}
});
}
function bboxOfElements(els: OsmElement[]): BBox {
let s = 90, w = 180, n = -90, e = -180;
for (const el of els)
if (el.type === 'node' && el.lat != null && el.lon != null) {
s = Math.min(s, el.lat); n = Math.max(n, el.lat); w = Math.min(w, el.lon); e = Math.max(e, el.lon);
} else if (el.geometry) for (const g of el.geometry) { s = Math.min(s, g.lat); n = Math.max(n, g.lat); w = Math.min(w, g.lon); e = Math.max(e, g.lon); }
return [s, w, n, e];
}
async function runGeneration(els: OsmElement[], bbox: BBox, name: string) {
// постоянный сид: один и тот же город всегда генерируется одинаково (сид здания = OSM-id ⊕ сид мира)
const seed = 1;
const t0 = performance.now();
const world = await generateWorld(els, bbox, name, seed, progress, abort?.signal);
console.info(`world generated in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
progress('Сохранение…', 1);
try {
await saveWorld(world);
} catch (e) {
console.warn('save failed', e);
}
hideProgress();
showViewer(world);
}
// ------------------------------------------------------------------ старт
(async () => {
const params = new URLSearchParams(location.search);
if (params.has('last') && (await hasWorld())) {
const w = await loadWorld();
if (w) return showViewer(w);
}
showPicker();
})();
// для отладки из консоли
(window as any).citygen = { runGeneration, showViewer };