clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

main
atlas / render / data / plane.ts
plane.ts 5.6K
1// The `/data` plane, and the only place in the seam that reaches the network.2//3// One module owns fetching so the plane's three properties have one owner4// (issue #5 §9, and the ESLint rule that says so by name):5//6// THE URL SHAPES. `/data/catalog.json` and `/data/v/{slug}/{stamp12}/…`,7// and nothing else. A base carries the twelve-hex short stamp, so every URL8// under it names exactly one build.9//10// THE CACHE RULES. Everything under a base is immutable for a year, so a11// payload is fetched once per build and kept for as long as the page holds12// the build. The catalog is `no-store` and is never kept.13//14// THE 404 AS A SIGNAL. When a newer build takes a slug over, every URL15// under the old stamp is gone. A 404 from a base is not a broken link: it16// is the build having moved, and the page's cue to ask the server for a new17// scene. The seam says so on the stream and gives up on that base; it does18// not reload the page, because navigation is the application's to decide.19//20// Nothing here interprets a payload. The types are `payload.ts`, the packed21// locations are `atlasloc.ts`, and what to draw is the scene's business.2223import { logger } from "../log.ts";24import { LocationTable } from "./atlasloc.ts";25import type { Catalog, Lens, TextPayload, WorldPayload } from "./payload.ts";26import { tileFormat } from "./payload.ts";2728const log = logger("data");2930/** A build's URL prefix: `/data/v/<slug>/<stamp12>`. */31export type Base = string;3233/** Raised when a base answered 404 — the build moved out from under us. */34export class BuildMovedError extends Error {35 readonly url: string;3637 constructor(url: string) {38 super(`the build serving ${url} has moved; the scene is stale`);39 this.name = "BuildMovedError";40 this.url = url;41 }42}4344/**45 * One page's reading of the plane.46 *47 * Payloads are kept per URL, which is per build, because the URL names the48 * build. Two worlds of one volume share nothing but the base; two builds of49 * one volume share nothing at all, which is the whole cache story.50 */51export class DataPlane {52 private readonly payloads = new Map<string, Promise<unknown>>();5354 /** The catalog, composed at the moment it is asked for. Never kept. */55 async catalog(): Promise<Catalog> {56 const response = await fetch("/data/catalog.json", { cache: "no-store" });57 if (!response.ok) throw new Error(`the catalog answered ${response.status}`);58 return (await response.json()) as Catalog;59 }6061 /** `worlds/<slug>.json` — read when the world opens. */62 world(base: Base, world: string): Promise<WorldPayload> {63 return this.json<WorldPayload>(`${base}/worlds/${world}.json`);64 }6566 /** `worlds/<slug>.text` — read lazily, when a card opens. */67 text(base: Base, world: string): Promise<TextPayload> {68 return this.json<TextPayload>(`${base}/worlds/${world}.text`);69 }7071 /**72 * `worlds/<slug>.bin` — every point feature, as views over the downloaded73 * buffer. The response is read as an `ArrayBuffer` precisely so the views74 * can be built over it without a copy.75 */76 locations(base: Base, world: string): Promise<LocationTable> {77 return this.keep(`${base}/worlds/${world}.bin`, async (url) => {78 const buffer = await this.bytes(url);79 const table = LocationTable.over(buffer);80 log.info("the packed locations are open", {81 op: "render", path: url, count: table.count,82 });83 return table;84 });85 }8687 /** Where an icon asset lives. Icons are fetched by the browser, as images. */88 iconURL(base: Base, asset: string): string {89 return iconURL(base, asset);90 }9192 /** Where one tile lives, or null when the lens holds no such level. */93 tileURL(base: Base, lens: Lens, z: number, x: number, y: number): string | null {94 const extension = tileFormat(lens, z);95 if (!extension) return null;96 return `${base}/tiles/${lens.tiles}/${z}/${x}/${y}.${extension}`;97 }9899 private json<T>(url: string): Promise<T> {100 return this.keep(url, async (at) => (await this.response(at)).json() as Promise<T>);101 }102103 private async bytes(url: string): Promise<ArrayBuffer> {104 return (await this.response(url)).arrayBuffer();105 }106107 private async response(url: string): Promise<Response> {108 const response = await fetch(url);109 if (response.status === 404) {110 log.warn("a payload is gone, which means the build moved", {111 op: "render", path: url,112 });113 throw new BuildMovedError(url);114 }115 if (!response.ok) throw new Error(`${url} answered ${response.status}`);116 return response;117 }118119 private keep<T>(url: string, read: (url: string) => Promise<T>): Promise<T> {120 const held = this.payloads.get(url) as Promise<T> | undefined;121 if (held) return held;122 // A failed read is forgotten rather than kept: a network hiccup should123 // cost a retry, not the rest of the session.124 const reading = read(url).catch((error: unknown) => {125 this.payloads.delete(url);126 throw error;127 });128 this.payloads.set(url, reading);129 return reading;130 }131}132133/**134 * Where an icon asset lives.135 *136 * A free function as well as a method because an icon is the one thing under137 * a base that something other than the reader fetches: the browser asks for138 * it as an image, and both panes have to name it the same way or they compose139 * two rasters of one symbol.140 *141 * Every segment is encoded and the separators are not: an asset path is a142 * path, and a curated one may carry a space or a bracket — `Vault 101 (Ext)`143 * — which is a 404 if it goes onto the wire as it was written.144 */145export function iconURL(base: Base, asset: string): string {146 const path = asset.split("/").map((segment) => encodeURIComponent(segment)).join("/");147 return `${base}/icons/${path}`;148}