Files
citygen/client/tiles.ts
T

56 lines
3.1 KiB
TypeScript

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<unknown> {
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<string, CityTile>();
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<string, CityTile>();
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(); }
}