import { assembleWorld, intersects, parseManifest, parseTile, type Bounds, type CityManifest, type CityTile } from '../src/city/format'; /** Explicit gzip files work even on static hosts without Content-Encoding headers. */ export async function fetchJson(url: string, signal?: AbortSignal, sha256?: string): Promise { const response = await fetch(url, { signal }); if (!response.ok) throw new Error(`HTTP ${response.status}: ${url}`); const bytes = new Uint8Array(await response.arrayBuffer()); let blob = new Blob([bytes]); if (bytes[0] === 0x1f && bytes[1] === 0x8b) { blob = await new Response(blob.stream().pipeThrough(new DecompressionStream('gzip'))).blob(); } const content = await blob.arrayBuffer(); if (sha256) { const hash = globalThis.crypto?.subtle ? new Uint8Array(await crypto.subtle.digest('SHA-256', content)) : (await import('@noble/hashes/sha2.js')).sha256(new Uint8Array(content)); const digest = [...hash].map(n=>n.toString(16).padStart(2,'0')).join(''); if (digest !== sha256) throw new Error(`City tile checksum mismatch: ${url}`); } return JSON.parse(new TextDecoder().decode(content)); } export class TileLoader { private cache = new Map(); private pending: AbortController | null = null; private revision = 0; constructor(readonly manifest: CityManifest, readonly manifestUrl: string, private maxTiles = 128) {} static async open(url: string, signal?: AbortSignal) { const absolute = new URL(url, location.href).href; return new TileLoader(parseManifest(await fetchJson(absolute, signal)), absolute); } async load(bounds: Bounds) { const revision = ++this.revision; this.pending?.abort(); const controller = this.pending = new AbortController(); const wanted = this.manifest.tiles.filter(t => intersects(t.bounds, bounds)); if (wanted.length > this.maxTiles) throw new Error(`Слишком большой обзор: ${wanted.length} тайлов. Приблизьте карту (лимит ${this.maxTiles}).`); const loaded = new Map(); let cursor = 0; // Bound HTTP concurrency and preserve old viewport until every new tile is available. try { await Promise.all(Array.from({ length: Math.min(6, wanted.length) }, async () => { while (cursor < wanted.length) { const ref = wanted[cursor++]; let tile = this.cache.get(ref.id); if (!tile) tile = parseTile(await fetchJson(new URL(ref.url, this.manifestUrl).href, controller.signal, ref.sha256), this.manifest.city.id, ref.id); loaded.set(ref.id, tile); } })); } catch (error) { controller.abort(); throw error; } if (revision !== this.revision) throw new DOMException('Superseded viewport', 'AbortError'); for (const [id, tile] of loaded) { this.cache.delete(id); this.cache.set(id, tile); } while (this.cache.size > this.maxTiles) this.cache.delete(this.cache.keys().next().value!); return assembleWorld(this.manifest, wanted.map(t => loaded.get(t.id)!)); } emptyWorld() { return assembleWorld(this.manifest, []); } destroy() { ++this.revision; this.pending?.abort(); this.cache.clear(); } }