clone

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

feat/atlas-next-horizon
atlas / render / data / plane.ts
plane.ts 14.8K
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// The one interpretation here is structural: schema-described typed columns21// become the semantic Volume graph. Presentation decisions remain the world's.2223import { logger } from "../log.ts";24import type { Catalog, Lens } from "./payload.ts";25import { tileFormat, tileTemplate } from "./payload.ts";26import {27 type Asset, type Feature, type Geometry, type Property, type PropertyValue,28 type Volume, type World,29} from "./semantic.ts";3031const log = logger("data");32const MAX_VISIBLE_FEATURE_PAGES = 32;3334/** A build's URL prefix: `/data/v/<slug>/<stamp12>`. */35export type Base = string;3637export interface OpenWorld {38 readonly volume: Volume;39 readonly world: World;40}4142export interface FeatureWindow {43 readonly x: number;44 readonly y: number;45 readonly zoom: number;46}4748/** Raised when a base answered 404 — the build moved out from under us. */49export class BuildMovedError extends Error {50 readonly url: string;5152 constructor(url: string) {53 super(`the build serving ${url} has moved; the scene is stale`);54 this.name = "BuildMovedError";55 this.url = url;56 }57}5859/**60 * One page's reading of the plane.61 *62 * Native volumes are kept per base, which is per build, because the URL names the63 * build. Two worlds of one volume share nothing but the base; two builds of64 * one volume share nothing at all, which is the whole cache story.65 */66export class DataPlane {67 private readonly payloads = new Map<string, Promise<unknown>>();6869 /** The catalog, composed at the moment it is asked for. Never kept. */70 async catalog(): Promise<Catalog> {71 const response = await fetch("/data/catalog.json", { cache: "no-store" });72 if (!response.ok) throw new Error(`the catalog answered ${response.status}`);73 return (await response.json()) as Catalog;74 }7576 /** One semantic world, reconstructed from the schema-described tables. */77 async world(base: Base, world: string, window: FeatureWindow | null = null): Promise<OpenWorld> {78 const outline = await this.outline(base);79 const found = outline.worlds.find((candidate) => candidate.id === world);80 if (!found) throw new Error(`${base} does not contain world ${world}`);81 const bounds = visibleBounds(found.coordinateSpace.extent, window);82 const pages = await Promise.all(found.featureSets.map((set) => this.features(base, set.id, bounds)));83 const opened = {84 ...found,85 featureSets: found.featureSets.map((set, index) => ({ ...set, features: pages[index] ?? [] })),86 };87 const volume = {88 ...outline,89 worlds: outline.worlds.map((candidate) => candidate.id === world ? opened : candidate),90 };91 log.info("the semantic world is open", {92 op: "render", path: base, world, features: pages.reduce((sum, page) => sum + page.length, 0),93 });94 return { volume, world: opened };95 }9697 /** Where an icon asset lives. Icons are fetched by the browser, as images. */98 iconURL(base: Base, asset: string): string {99 return iconURL(base, asset);100 }101102 /** Where one tile lives, or null when the lens holds no such level. */103 tileURL(base: Base, lens: Lens, z: number, x: number, y: number): string | null {104 const extension = tileFormat(lens, z);105 if (!extension) return null;106 return `${base}/${fillTemplate(tileTemplate(lens), z, x, y, extension)}`;107 }108109 private outline(base: Base): Promise<Volume> {110 return this.keep(`${base}/@outline`, async () => decodeOutline(await this.json<unknown>(`${base}/outline.json`)));111 }112113 private async features(base: Base, set: string, bounds: readonly [number, number, number, number]): Promise<readonly Feature[]> {114 const features: Feature[] = [];115 let after = "";116 for (let pageIndex = 0; pageIndex < MAX_VISIBLE_FEATURE_PAGES; pageIndex++) {117 const query = new URLSearchParams({118 limit: "1000", minX: String(bounds[0]), minY: String(bounds[1]),119 maxX: String(bounds[2]), maxY: String(bounds[3]),120 });121 if (after) query.set("after", after);122 const page = await this.json<Record<string, unknown>>(123 `${base}/features/${encodeURIComponent(set)}.json?${query.toString()}`,124 );125 for (const feature of array(page.features)) features.push(decodeFeature(object(feature)));126 after = text(page.next);127 if (!after) return features;128 }129 throw new Error(`visible feature window for ${set} exceeds ${MAX_VISIBLE_FEATURE_PAGES * 1000} features`);130 }131132 private json<T>(url: string): Promise<T> {133 return this.keep(url, async (at) => (await this.response(at)).json() as Promise<T>);134 }135136 private async response(url: string): Promise<Response> {137 const response = await fetch(url);138 if (response.status === 404) {139 log.warn("a payload is gone, which means the build moved", {140 op: "render", path: url,141 });142 throw new BuildMovedError(url);143 }144 if (!response.ok) throw new Error(`${url} answered ${response.status}`);145 return response;146 }147148 private keep<T>(url: string, read: (url: string) => Promise<T>): Promise<T> {149 const held = this.payloads.get(url) as Promise<T> | undefined;150 if (held) return held;151 // A failed read is forgotten rather than kept: a network hiccup should152 // cost a retry, not the rest of the session.153 const reading = read(url).catch((error: unknown) => {154 this.payloads.delete(url);155 throw error;156 });157 this.payloads.set(url, reading);158 return reading;159 }160}161162function visibleBounds(163 extent: readonly [number, number, number, number],164 window: FeatureWindow | null,165): readonly [number, number, number, number] {166 if (!window || !Number.isFinite(window.x) || !Number.isFinite(window.y) || !Number.isFinite(window.zoom)) return extent;167 const width = extent[2] - extent[0];168 const height = extent[3] - extent[1];169 const span = Math.max(width, height) / 2 ** Math.max(0, window.zoom);170 const x = Math.min(Math.max(window.x, extent[0]), extent[2]);171 const y = Math.min(Math.max(-window.y, extent[1]), extent[3]);172 return [173 Math.max(extent[0], x - span / 2), Math.max(extent[1], y - span / 2),174 Math.min(extent[2], x + span / 2), Math.min(extent[3], y + span / 2),175 ];176}177178function decodeOutline(source: unknown): Volume {179 const root = object(source);180 if (number(root.version) !== 1) throw new Error("native outline version is unsupported");181 const assets = array(root.assets).map((entry) => decodeAsset(object(entry)));182 return {183 id: text(root.id), title: text(root.title), assets: new Map(assets.map((asset) => [asset.id, asset])),184 worlds: array(root.worlds).map((entry) => decodeWorld(object(entry))),185 };186}187188function decodeWorld(raw: Record<string, unknown>): World {189 const coordinate = object(raw.coordinateSpace);190 const presentation = object(raw.presentation);191 return {192 id: text(raw.id), title: text(raw.title), claims: wireProperties(raw.claims),193 coordinateSpace: {194 id: text(coordinate.id), kind: text(coordinate.kind), unit: text(coordinate.unit),195 definition: text(coordinate.definition), extent: tuple4(coordinate.extent),196 sourceZoom: number(coordinate.sourceZoom), originX: number(coordinate.originX),197 originY: number(coordinate.originY), tileSize: number(coordinate.tileSize), size: number(coordinate.size),198 },199 featureSets: array(raw.featureSets).map((entry) => {200 const set = object(entry);201 return {202 id: text(set.id), title: text(set.title), semanticType: text(set.semanticType),203 properties: array(set.properties).map((field) => {204 const value = object(field);205 return { id: text(value.id), name: text(value.name), kind: number(value.kind), optional: Boolean(value.optional) };206 }),207 claims: wireProperties(set.claims), features: [],208 };209 }),210 rasterPyramids: array(raw.rasterPyramids).map((entry) => {211 const raster = object(entry);212 return {213 id: text(raster.id), name: text(raster.name), codec: text(raster.codec), tileSize: number(raster.tileSize),214 minZoom: number(raster.minZoom), maxZoom: number(raster.maxZoom), fullZoom: number(raster.fullZoom),215 sourceZoom: number(raster.sourceZoom), template: text(raster.template), formats: array(raster.formats).map(text),216 bounds: raster.bounds ? rect(object(raster.bounds)) : null, surface: raster.surface ? rect(object(raster.surface)) : null,217 interpolate: Boolean(raster.interpolate), background: text(raster.background), shard: number(raster.shard),218 coverage: array(raster.coverage).map((entry) => {219 const coverage = object(entry);220 return {221 zoom: number(coverage.zoom), x: number(coverage.x), y: number(coverage.y), w: number(coverage.w),222 h: number(coverage.h), bits: bytes(coverage.bits),223 };224 }),225 };226 }),227 presentation: {228 id: text(presentation.id), title: text(presentation.title),229 styles: array(presentation.styles).map((entry) => {230 const style = object(entry);231 return {232 id: text(style.id), symbol: text(style.symbol), icon: text(style.icon), iconAsset: text(style.iconAsset),233 iconPicture: Boolean(style.iconPicture), renderAs: text(style.renderAs), stroke: text(style.stroke), fill: text(style.fill),234 };235 }),236 layers: array(presentation.layers).map((entry) => {237 const layer = object(entry);238 return {239 id: text(layer.id), featureSet: text(layer.featureSet), style: text(layer.style), group: text(layer.group),240 labelPolicy: text(layer.labelPolicy), visible: Boolean(layer.visible), order: number(layer.order),241 minZoom: number(layer.minZoom), maxZoom: number(layer.maxZoom),242 };243 }),244 legend: array(presentation.legend).map((entry) => {245 const legend = object(entry);246 return { layer: text(legend.layer), label: text(legend.label), order: number(legend.order) };247 }),248 },249 };250}251252function decodeFeature(raw: Record<string, unknown>): Feature {253 const center = raw.center;254 return {255 id: text(raw.id), title: text(raw.title), subtitle: text(raw.subtitle), description: text(raw.description),256 center: center ? tuple2(center) : null, shard: number(raw.shard), geometry: decodeWireGeometry(object(raw.geometry)),257 properties: wireProperties(raw.properties),258 relationships: array(raw.relationships).map((entry) => {259 const edge = object(entry);260 return { predicate: text(edge.predicate), target: text(edge.target) };261 }),262 provenance: array(raw.provenance).map((entry) => {263 const evidence = object(entry);264 return { source: text(evidence.source), nativeID: text(evidence.nativeId), capturedAt: text(evidence.capturedAt) };265 }),266 };267}268269function decodeWireGeometry(raw: Record<string, unknown>): Geometry {270 const kind = number(raw.kind);271 if (kind !== 1 && kind !== 2 && kind !== 3) throw new Error(`geometry kind ${kind} is unsupported`);272 return {273 kind,274 parts: array(raw.parts).map((entry) => ({275 rings: array(object(entry).rings).map((ring) => array(ring).map(tuple2)),276 })),277 };278}279280function wireProperties(source: unknown): readonly Property[] {281 return array(source).map((entry) => {282 const property = object(entry);283 const kind = number(property.kind);284 return { id: text(property.id), name: text(property.name), kind, value: wirePropertyValue(kind, property.value) };285 });286}287288function wirePropertyValue(kind: number, value: unknown): PropertyValue {289 switch (kind) {290 case 1: return Boolean(value);291 case 2: return BigInt(text(value));292 case 3: return number(value);293 case 4: return text(value);294 case 5: return bytes(value);295 case 6: return text(value);296 default: throw new Error("property has no typed value");297 }298}299300function decodeAsset(raw: Record<string, unknown>): Asset {301 return { id: text(raw.id), mediaType: text(raw.mediaType), path: text(raw.path), provenance: text(raw.provenance) };302}303304function rect(raw: Record<string, unknown>): { x: number; y: number; width: number; height: number } {305 return { x: number(raw.x), y: number(raw.y), width: number(raw.width), height: number(raw.height) };306}307308function object(value: unknown): Record<string, unknown> {309 if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("native response is not an object");310 return value as Record<string, unknown>;311}312313function array(value: unknown): unknown[] { return Array.isArray(value) ? value : []; }314function text(value: unknown): string { return typeof value === "string" ? value : ""; }315function number(value: unknown): number { return typeof value === "number" ? value : Number(value ?? 0); }316function tuple2(value: unknown): readonly [number, number] {317 const held = array(value);318 return [number(held[0]), number(held[1])];319}320function tuple4(value: unknown): readonly [number, number, number, number] {321 const held = array(value);322 return [number(held[0]), number(held[1]), number(held[2]), number(held[3])];323}324function bytes(value: unknown): Uint8Array {325 if (typeof value !== "string" || !value) return new Uint8Array();326 return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));327}328329/**330 * Where an icon asset lives.331 *332 * A free function as well as a method because an icon is the one thing under333 * a base that something other than the reader fetches: the browser asks for334 * it as an image, and both panes have to name it the same way or they compose335 * two rasters of one symbol.336 *337 * Every segment is encoded and the separators are not: an asset path is a338 * path, and a curated one may carry a space or a bracket — `Vault 101 (Ext)`339 * — which is a 404 if it goes onto the wire as it was written.340 */341export function iconURL(base: Base, asset: string): string {342 const path = asset.split("/").map((segment) => encodeURIComponent(segment)).join("/");343 return `${base}/${path}`;344}345346export function fillTemplate(template: string, z: number, x: number, y: number, format: string): string {347 return template.replaceAll("{z}", String(z)).replaceAll("{x}", String(x))348 .replaceAll("{y}", String(y)).replaceAll("{format}", format);349}