// Просмотрщик мира: камера (перетаскивание, колесо, щипок), этажи, выбор здания. 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 = (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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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(); 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', '© OpenStreetMap 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 = `
${esc(w.name || 'Без названия')}
${w.buildings.length.toLocaleString('ru')} зданий · ${floors.toLocaleString('ru')} этажей · ${rooms.toLocaleString('ru')} комнат · ${furn.toLocaleString('ru')} предметов · ${homes.toLocaleString('ru')} квартир/домов · ~${beds.toLocaleString('ru')} жителей
`; top.querySelector('[data-a=new]')!.addEventListener('click', () => this.actions.onNew()); top.querySelector('[data-a=export]')!.addEventListener('click', () => this.actions.onExport()); top.querySelectorAll('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 = '
Этаж
'; 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('.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(); 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 ? `OSM` : ''; this.info.innerHTML = `
${esc(b.name || KIND_NAME[b.kind])}
${b.name ? esc(KIND_NAME[b.kind]) + ' · ' : ''}${b.use ? esc(b.use) + ' · ' : ''}${b.address ? esc(b.address) : ''}
Этажей${b.levels} ${src}
Площадь застройки${Math.round(b.area)} м²
${homes ? `
Жилых помещений${homes}
` : ''} ${b.beds ? `
Спальных мест${b.beds}
` : ''}
${['⌂', ...Array.from({ length: Math.min(b.levels, 40) }, (_, i) => String(i + 1))].map((t, i) => ``).join('')}
${f ? `
${f.rooms.length} помещений: ${esc(roomList)}
` : ''} `; this.info.querySelector('.close')!.addEventListener('click', () => this.select(null)); this.info.querySelectorAll('[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); } }