56 lines
3.1 KiB
TypeScript
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(); }
|
|
}
|