Files
citygen/src/view/viewer.ts
T

289 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Просмотрщик мира: камера (перетаскивание, колесо, щипок), этажи, выбор здания.
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);
}
}