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 / atlasloc.ts
atlasloc.ts 4.9K
1// `ATLASLOC` version 3, read where it landed.2//3// `docs/format.md` §7 is the whole specification of these bytes: parallel4// little-endian typed arrays, laid out so a reader can build views directly5// over the downloaded buffer with no parsing and no per-feature allocation.6// This module is that reader, and it keeps the promise the layout was7// designed for — nothing here copies a column.8//9// Titles are the one exception, and only on demand: a UTF-8 run is decoded10// when someone asks for it, and the decoded string is remembered, because a11// world's titles are read by the search, the dock and every label, and12// decoding two thousand of them twice is work nobody asked for.13//14// The reader is deliberately strict about three things and lenient about15// everything else: the magic, the version, and the title offsets. Those are16// the only values in the payload that can point outside it.1718const MAGIC = "ATLASLOC";19const VERSION = 3;20const HEADER = 16;2122/** One packed point feature, materialised only when something asks. */23export interface PackedLocation {24 readonly id: number;25 readonly owner: number;26 readonly lat: number;27 readonly lng: number;28 readonly member: number;29 readonly shard: number;30 readonly title: string;31}3233/**34 * Views over a downloaded `worlds/<slug>.bin`.35 *36 * The columns are public because everything that draws wants them as arrays,37 * not as objects: a hit test walks `lat`/`lng` without touching a title, and38 * a filter walks `owner` without touching anything else.39 */40export class LocationTable {41 readonly count: number;42 readonly id: Int32Array;43 readonly lat: Float32Array;44 readonly lng: Float32Array;45 readonly member: Int32Array;46 readonly shard: Int32Array;47 readonly owner: Uint16Array;4849 private readonly titleOffsets: Uint32Array;50 private readonly titleBytes: Uint8Array;51 private readonly titles: (string | undefined)[];52 private static readonly decoder = new TextDecoder();5354 private constructor(buffer: ArrayBuffer) {55 const bytes = new Uint8Array(buffer);56 if (bytes.byteLength < HEADER) {57 throw new Error("ATLASLOC: the payload is shorter than its own header");58 }59 const magic = LocationTable.decoder.decode(bytes.subarray(0, 8));60 if (magic !== MAGIC) {61 throw new Error(`ATLASLOC: magic is ${JSON.stringify(magic)}, not ${MAGIC}`);62 }63 const header = new DataView(buffer);64 const version = header.getUint16(8, true);65 if (version !== VERSION) {66 // A reader refuses a version it does not know (format.md §7.3). Guessing67 // at a layout is worse than saying so.68 throw new Error(`ATLASLOC: version ${version} is not version ${VERSION}`);69 }70 const n = header.getUint32(10, true);71 this.count = n;7273 // Every offset below is format.md §7's table, in its own order. The two74 // reserved bytes at 14 exist so each column that follows is four-byte75 // aligned, which is what makes these views legal at all.76 this.id = new Int32Array(buffer, HEADER, n);77 this.lat = new Float32Array(buffer, HEADER + 4 * n, n);78 this.lng = new Float32Array(buffer, HEADER + 8 * n, n);79 this.member = new Int32Array(buffer, HEADER + 12 * n, n);80 this.shard = new Int32Array(buffer, HEADER + 16 * n, n);81 this.titleOffsets = new Uint32Array(buffer, HEADER + 20 * n, n + 1);82 this.owner = new Uint16Array(buffer, 20 + 24 * n, n);8384 const start = 20 + 26 * n;85 const end = this.titleOffsets[n] ?? 0;86 if (start + end > bytes.byteLength) {87 throw new Error("ATLASLOC: the title region runs past the end of the payload");88 }89 this.titleBytes = bytes.subarray(start, start + end);90 this.titles = new Array<string | undefined>(n);91 }9293 /** Build the views. Throws when the bytes are not this format. */94 static over(buffer: ArrayBuffer): LocationTable {95 return new LocationTable(buffer);96 }9798 /** Location `i`'s title, decoded once and kept. */99 title(i: number): string {100 const held = this.titles[i];101 if (held !== undefined) return held;102 const from = this.titleOffsets[i] ?? 0;103 const to = this.titleOffsets[i + 1] ?? from;104 // Non-decreasing offsets are the producer's promise; a payload that broke105 // it gets an empty title rather than a thrown page.106 const decoded = to > from107 ? LocationTable.decoder.decode(this.titleBytes.subarray(from, to))108 : "";109 this.titles[i] = decoded;110 return decoded;111 }112113 /** One location as a record. Allocates: for cards and picks, not for draws. */114 at(i: number): PackedLocation {115 if (i < 0 || i >= this.count) throw new RangeError(`ATLASLOC: no location ${i}`);116 return {117 id: this.id[i] ?? 0,118 owner: this.owner[i] ?? 0,119 lat: this.lat[i] ?? 0,120 lng: this.lng[i] ?? 0,121 member: this.member[i] ?? 0,122 shard: this.shard[i] ?? 0,123 title: this.title(i),124 };125 }126127 /** Every location, in the packed payload's own order. */128 *all(): Generator<PackedLocation> {129 for (let i = 0; i < this.count; i++) yield this.at(i);130 }131}