Initial commit: browser OSM city generator
This commit is contained in:
commit
2d4bad0dc7
32 files changed
+5915
No files matched your search
+237
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 };
|
||||
Reference in new issue
Block a user