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

+288
View File
@@ -0,0 +1,288 @@
// Просмотрщик мира: камера (перетаскивание, колесо, щипок), этажи, выбор здания.
import type { Building, Vec, World } from '../types';
import { Renderer, type Camera, type ViewOpts } from './renderer';
import { KIND_NAME, ROOM_NAME } from './style';
import { centroid } from '../util/geom';
const el = <K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, html?: string) => {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (html !== undefined) e.innerHTML = html;
return e;
};
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]!));
export class Viewer {
readonly root = el('div', 'viewer');
private canvas = el('canvas', 'world-canvas');
private ctx = this.canvas.getContext('2d')!;
private renderer: Renderer;
cam: Camera;
opts: ViewOpts = { floor: null, overrides: new Map(), selected: null, furniture: true, labels: true };
private raf = 0;
private floorBar = el('div', 'floor-bar card');
private info = el('div', 'info card');
private scale = el('div', 'scale');
private maxLevel: number;
private pointers = new Map<number, { x: number; y: number }>();
private gesture: { x: number; y: number; cam: Camera; d?: number; moved: boolean } | null = null;
constructor(parent: HTMLElement, public world: World, private actions: { onNew: () => void; onExport: () => void }) {
this.renderer = new Renderer(world);
const B = world.bounds;
this.cam = { x: (B.minX + B.maxX) / 2, y: (B.minY + B.maxY) / 2, z: 1 };
this.maxLevel = Math.min(40, Math.max(1, ...world.buildings.map((b) => b.levels)));
this.root.append(this.canvas, this.buildTop(), this.floorBar, this.info, this.scale, el('div', 'attribution', '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors'));
parent.appendChild(this.root);
this.info.hidden = true;
this.buildFloorBar();
new ResizeObserver(() => this.resize()).observe(this.root);
this.bindInput();
requestAnimationFrame(() => {
this.resize();
this.fitBounds();
});
}
destroy() {
this.root.remove();
window.removeEventListener('keydown', this.onKey);
}
private buildTop(): HTMLElement {
const top = el('div', 'viewer-top card');
const w = this.world;
let floors = 0, rooms = 0, furn = 0, beds = 0, homes = 0;
for (const b of w.buildings) {
floors += b.floors.length;
beds += b.beds;
homes += b.units.filter((u) => u.kind === 'home').length;
for (const f of b.floors) { rooms += f.rooms.length; furn += f.props.length; }
}
top.innerHTML = `
<div class="title">${esc(w.name || 'Без названия')}</div>
<div class="stats">${w.buildings.length.toLocaleString('ru')} зданий · ${floors.toLocaleString('ru')} этажей · ${rooms.toLocaleString('ru')} комнат · ${furn.toLocaleString('ru')} предметов · ${homes.toLocaleString('ru')} квартир/домов · ~${beds.toLocaleString('ru')} жителей</div>
<div class="row">
<button class="btn" data-a="new">Новая карта</button>
<button class="btn" data-a="export" title="Скачать мир целиком (JSON) — формат для будущей игры">Экспорт мира</button>
<label class="chip"><input type="checkbox" data-t="furniture" checked> Мебель</label>
<label class="chip"><input type="checkbox" data-t="labels" checked> Подписи</label>
</div>`;
top.querySelector('[data-a=new]')!.addEventListener('click', () => this.actions.onNew());
top.querySelector('[data-a=export]')!.addEventListener('click', () => this.actions.onExport());
top.querySelectorAll<HTMLInputElement>('input[data-t]').forEach((inp) =>
inp.addEventListener('change', () => {
(this.opts as any)[inp.dataset.t!] = inp.checked;
this.request();
}));
return top;
}
private buildFloorBar() {
const bar = this.floorBar;
bar.innerHTML = '<div class="floor-title">Этаж</div>';
const list = el('div', 'floor-list');
const mk = (label: string, value: number | null, title: string) => {
const b = el('button', 'floor-btn', label);
b.title = title;
b.dataset.v = value === null ? 'roof' : String(value);
b.addEventListener('click', () => this.setFloor(value));
return b;
};
list.appendChild(mk('⌂', null, 'Крыши'));
for (let l = this.maxLevel - 1; l >= 0; l--) list.appendChild(mk(String(l + 1), l, `${l + 1}-й этаж`));
bar.appendChild(list);
bar.appendChild(el('div', 'floor-hint', 'PgUp / PgDn'));
this.syncFloorBar();
}
private syncFloorBar() {
const v = this.opts.floor === null ? 'roof' : String(this.opts.floor);
this.floorBar.querySelectorAll<HTMLButtonElement>('.floor-btn').forEach((b) => b.classList.toggle('on', b.dataset.v === v));
}
setFloor(v: number | null) {
this.opts.floor = v;
this.opts.overrides.clear();
this.syncFloorBar();
this.updateInfo();
this.request();
}
private resize() {
const r = this.root.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
this.canvas.width = Math.round(r.width * dpr);
this.canvas.height = Math.round(r.height * dpr);
this.canvas.style.width = r.width + 'px';
this.canvas.style.height = r.height + 'px';
this.request();
}
fitBounds() {
const B = this.world.bounds;
const r = this.root.getBoundingClientRect();
this.cam.x = (B.minX + B.maxX) / 2;
this.cam.y = (B.minY + B.maxY) / 2;
this.cam.z = Math.min(r.width / (B.maxX - B.minX), r.height / (B.maxY - B.minY)) * 0.92;
this.request();
}
/** Перелёт к точке (для отладки и будущих событий). */
lookAt(p: Vec, z: number) {
this.cam = { x: p[0], y: p[1], z };
this.request();
}
request() {
if (this.raf) return;
this.raf = requestAnimationFrame(() => {
this.raf = 0;
const r = this.root.getBoundingClientRect();
this.renderer.draw(this.ctx, r.width, r.height, this.cam, this.opts, window.devicePixelRatio || 1);
this.updateScale();
});
}
private updateScale() {
const z = this.cam.z;
const target = 120 / z;
const nice = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000].find((n) => n >= target * 0.6) ?? 5000;
this.scale.style.width = nice * z + 'px';
this.scale.textContent = nice >= 1000 ? `${nice / 1000} км` : `${nice} м`;
}
private screenToWorld(x: number, y: number): Vec {
const r = this.root.getBoundingClientRect();
return [(x - r.width / 2) / this.cam.z + this.cam.x, (y - r.height / 2) / this.cam.z + this.cam.y];
}
private zoomAt(x: number, y: number, factor: number) {
const before = this.screenToWorld(x, y);
this.cam.z = Math.max(0.05, Math.min(120, this.cam.z * factor));
const after = this.screenToWorld(x, y);
this.cam.x += before[0] - after[0];
this.cam.y += before[1] - after[1];
this.request();
}
private onKey = (e: KeyboardEvent) => {
if ((e.target as HTMLElement)?.tagName === 'INPUT') return;
const f = this.opts.floor;
if (e.key === 'PageUp' || e.key === ']') this.setFloor(f === null ? 0 : Math.min(this.maxLevel - 1, f + 1));
else if (e.key === 'PageDown' || e.key === '[') this.setFloor(f === null || f === 0 ? null : f - 1);
else if (e.key === 'Escape') this.select(null);
else if (e.key === '+' || e.key === '=') this.zoomAt(this.canvas.clientWidth / 2, this.canvas.clientHeight / 2, 1.3);
else if (e.key === '-') this.zoomAt(this.canvas.clientWidth / 2, this.canvas.clientHeight / 2, 1 / 1.3);
else return;
e.preventDefault();
};
private bindInput() {
const c = this.canvas;
c.addEventListener('wheel', (e) => {
e.preventDefault();
const k = e.ctrlKey ? 0.01 : 0.0015;
this.zoomAt(e.offsetX, e.offsetY, Math.exp(-e.deltaY * k));
}, { passive: false });
c.addEventListener('pointerdown', (e) => {
c.setPointerCapture(e.pointerId);
this.pointers.set(e.pointerId, { x: e.offsetX, y: e.offsetY });
const ps = [...this.pointers.values()];
const mx = ps.reduce((s, p) => s + p.x, 0) / ps.length, my = ps.reduce((s, p) => s + p.y, 0) / ps.length;
this.gesture = { x: mx, y: my, cam: { ...this.cam }, d: ps.length === 2 ? Math.hypot(ps[0].x - ps[1].x, ps[0].y - ps[1].y) : undefined, moved: false };
});
c.addEventListener('pointermove', (e) => {
if (!this.pointers.has(e.pointerId) || !this.gesture) return;
this.pointers.set(e.pointerId, { x: e.offsetX, y: e.offsetY });
const ps = [...this.pointers.values()];
const mx = ps.reduce((s, p) => s + p.x, 0) / ps.length, my = ps.reduce((s, p) => s + p.y, 0) / ps.length;
const g = this.gesture;
if (Math.hypot(mx - g.x, my - g.y) > 4) g.moved = true;
if (ps.length === 2 && g.d) {
const d = Math.hypot(ps[0].x - ps[1].x, ps[0].y - ps[1].y);
this.cam.z = Math.max(0.05, Math.min(120, g.cam.z * (d / g.d)));
g.moved = true;
}
const r = this.root.getBoundingClientRect();
const anchor: Vec = [(g.x - r.width / 2) / g.cam.z + g.cam.x, (g.y - r.height / 2) / g.cam.z + g.cam.y];
this.cam.x = anchor[0] - (mx - r.width / 2) / this.cam.z;
this.cam.y = anchor[1] - (my - r.height / 2) / this.cam.z;
this.request();
});
const end = (e: PointerEvent) => {
const g = this.gesture;
this.pointers.delete(e.pointerId);
if (g && !g.moved && this.pointers.size === 0) {
const b = this.renderer.buildingAt(this.screenToWorld(e.offsetX, e.offsetY));
this.select(b?.id ?? null);
}
if (this.pointers.size) {
const ps = [...this.pointers.values()];
this.gesture = { x: ps[0].x, y: ps[0].y, cam: { ...this.cam }, moved: true };
} else this.gesture = null;
};
c.addEventListener('pointerup', end);
c.addEventListener('pointercancel', end);
c.addEventListener('dblclick', (e) => this.zoomAt(e.offsetX, e.offsetY, 2));
window.addEventListener('keydown', this.onKey);
}
select(id: string | null) {
this.opts.selected = id;
this.updateInfo();
this.request();
}
private updateInfo() {
const id = this.opts.selected;
const b = id ? this.world.buildings.find((x) => x.id === id) : null;
if (!b) {
this.info.hidden = true;
return;
}
this.info.hidden = false;
const cur = this.opts.overrides.has(b.id) ? this.opts.overrides.get(b.id)! : this.opts.floor;
const lvl = cur !== null && cur < b.levels ? cur : null;
const f = lvl !== null ? b.floors[lvl] : null;
const src = b.levelsSource === 'osm' ? 'из OSM' : b.levelsSource === 'height' ? 'по высоте' : 'оценка';
const counts = new Map<string, number>();
if (f) for (const r of f.rooms) if (ROOM_NAME[r.kind]) counts.set(ROOM_NAME[r.kind], (counts.get(ROOM_NAME[r.kind]) ?? 0) + 1);
const roomList = [...counts.entries()].map(([k, n]) => (n > 1 ? `${k} ×${n}` : k)).join(', ');
const homes = b.units.filter((u) => u.kind === 'home').length;
const osm = b.osmId ? `<a href="https://www.openstreetmap.org/${b.osmId}" target="_blank" rel="noopener">OSM</a>` : '';
this.info.innerHTML = `
<button class="close" title="Закрыть">×</button>
<div class="title">${esc(b.name || KIND_NAME[b.kind])}</div>
<div class="sub">${b.name ? esc(KIND_NAME[b.kind]) + ' · ' : ''}${b.use ? esc(b.use) + ' · ' : ''}${b.address ? esc(b.address) : ''}</div>
<div class="kv"><span>Этажей</span><b>${b.levels}</b> <i>${src}</i></div>
<div class="kv"><span>Площадь застройки</span><b>${Math.round(b.area)} м²</b></div>
${homes ? `<div class="kv"><span>Жилых помещений</span><b>${homes}</b></div>` : ''}
${b.beds ? `<div class="kv"><span>Спальных мест</span><b>${b.beds}</b></div>` : ''}
<div class="floor-pick">${['⌂', ...Array.from({ length: Math.min(b.levels, 40) }, (_, i) => String(i + 1))].map((t, i) => `<button class="floor-btn sm${(i === 0 && lvl === null) || lvl === i - 1 ? ' on' : ''}" data-l="${i - 1}">${t}</button>`).join('')}</div>
${f ? `<div class="rooms">${f.rooms.length} помещений: ${esc(roomList)}</div>` : ''}
<div class="links">${osm}<a href="#" data-a="zoom">Приблизить</a></div>`;
this.info.querySelector('.close')!.addEventListener('click', () => this.select(null));
this.info.querySelectorAll<HTMLButtonElement>('[data-l]').forEach((btn) =>
btn.addEventListener('click', () => {
const l = +btn.dataset.l!;
this.opts.overrides.set(b.id, l < 0 ? null : l);
this.updateInfo();
this.request();
}));
this.info.querySelector('[data-a=zoom]')!.addEventListener('click', (e) => {
e.preventDefault();
const c = centroid(b.footprint);
const span = Math.sqrt(b.area) * 1.8 + 10;
const r = this.root.getBoundingClientRect();
this.lookAt(c, Math.min(60, Math.min(r.width, r.height) / span));
if (this.opts.overrides.get(b.id) == null && this.opts.floor === null) this.opts.overrides.set(b.id, 0);
this.updateInfo();
});
}
selectBuilding(b: Building) {
this.select(b.id);
}
}