1// Which tiles exist, and where to ask for them.2//3// A lens is a pyramid over the volume's world square: `tileSize` pixels per4// tile edge, level `z` cut into `2^z` tiles per axis across the whole square,5// so level 0 is the square in one tile. That is the inherited constant set6// (issue #5 §5.1) and it is frozen; nothing here derives it from anything.7//8// Two rules make the difference between asking for what is there and asking9// for everything:10//11// BOUNDS. A lens fills a window of the square, not always the square. Mars12// is 8192 × 4096 of an 8192 square, so its level 6 holds 64 × 32 tiles and13// not 64 × 64 — and the 2,048 the corpus inventory records are exactly the14// ones inside the window.15//16// COVERAGE. Past `fullZoom` a pyramid holds only what the capture reached,17// and the per-level bitset says which. A reader must consult it before18// requesting a tile: asking for one that was never written is a wasted19// request, and the correct fallback is the parent tile drawn larger20// (format.md §6.3.1).21//22// Both together are what makes the base/detail layer split of the chart23// honest: the base layer draws the complete levels and can always be24// upsampled, the detail layer draws the patchy ones and asks only where the25// bitset says yes.2627import type { CoverageLevel, Lens, PixelRect, TileGrid } from "./payload.ts";28import { tileFormat, tileTemplate } from "./payload.ts";2930/** A half-open tile window at one level: `x0 ≤ x < x1`, `y0 ≤ y < y1`. */31export interface TileWindow {32 readonly x0: number;33 readonly x1: number;34 readonly y0: number;35 readonly y1: number;36}3738/** The world-pixel window a lens fills: its own bounds, or the whole square. */39export function lensWindow(lens: Lens, grid: TileGrid): PixelRect {40 return lens.bounds ?? { x: 0, y: 0, width: grid.size, height: grid.size };41}4243/** The world-pixel edge of one tile at zoom `z`. */44export function tileEdge(grid: TileGrid, z: number): number {45 return grid.size / 2 ** z;46}4748/** The tiles of level `z` the lens's window touches. */49export function tileWindowAt(lens: Lens, grid: TileGrid, z: number): TileWindow {50 const edge = tileEdge(grid, z);51 const window = lensWindow(lens, grid);52 const span = 2 ** z;53 const clamp = (value: number) => Math.min(Math.max(value, 0), span);54 return {55 x0: clamp(Math.floor(window.x / edge)),56 x1: clamp(Math.ceil((window.x + window.width) / edge)),57 y0: clamp(Math.floor(window.y / edge)),58 y1: clamp(Math.ceil((window.y + window.height) / edge)),59 };60}6162/**63 * A level's bitset, decoded once.64 *65 * `bits` is standard base64 over a row-major bitset of the level's window,66 * least significant bit first within each byte. The decode is `atob`, which67 * every host this seam runs in has — browser, headless browser, and the Node68 * that runs its tests.69 */70export class Coverage {71 private readonly bits: Uint8Array;7273 private readonly level: CoverageLevel;7475 constructor(level: CoverageLevel) {76 this.level = level;77 const binary = atob(level.bits);78 this.bits = new Uint8Array(binary.length);79 for (let i = 0; i < binary.length; i++) this.bits[i] = binary.charCodeAt(i);80 }8182 /** Whether tile `(x, y)` of this level was written. */83 has(x: number, y: number): boolean {84 const c = x - this.level.x;85 const r = y - this.level.y;86 if (c < 0 || r < 0 || c >= this.level.w || r >= this.level.h) return false;87 const i = r * this.level.w + c;88 return ((this.bits[i >> 3] ?? 0) & (1 << (i & 7))) !== 0;89 }90}9192/** A lens's coverage, decoded once per level, keyed by zoom. */93export class LensCoverage {94 private readonly levels = new Map<number, Coverage | null>();9596 private readonly lens: Lens;9798 constructor(lens: Lens) {99 this.lens = lens;100 }101102 /**103 * Whether tile `(z, x, y)` exists.104 *105 * A level with no coverage entry is fully covered, so the answer is yes106 * inside the lens's own window and no outside it: a request for a tile the107 * window never held is as wasted as a request for one the bitset denies.108 */109 has(grid: TileGrid, z: number, x: number, y: number): boolean {110 if (z < this.lens.minZoom || z > this.lens.maxZoom) return false;111 const window = tileWindowAt(this.lens, grid, z);112 if (x < window.x0 || x >= window.x1 || y < window.y0 || y >= window.y1) return false;113 let coverage = this.levels.get(z);114 if (coverage === undefined) {115 const declared = this.lens.coverage?.[String(z)];116 coverage = declared ? new Coverage(declared) : null;117 this.levels.set(z, coverage);118 }119 return coverage === null || coverage.has(x, y);120 }121}122123/** The path of one tile under the volume's base, or null where none exists. */124export function tilePath(lens: Lens, z: number, x: number, y: number): string | null {125 const extension = tileFormat(lens, z);126 if (!extension) return null;127 return tileTemplate(lens).replaceAll("{z}", String(z)).replaceAll("{x}", String(x))128 .replaceAll("{y}", String(y)).replaceAll("{format}", extension);129}130131/**132 * Every tile a lens holds, as inventory names (`<z>/<x>/<y>.<ext>`), in the133 * inventory's own order: z, then x, then y, numerically.134 *135 * This exists for the tests — the corpus per-lens inventories are the record136 * of what a pyramid holds, and a reader that agrees with them agrees about137 * bounds, about coverage, and about the format per level all at once.138 */139export function inventoryNames(lens: Lens, grid: TileGrid): string[] {140 const coverage = new LensCoverage(lens);141 const names: string[] = [];142 for (let z = lens.minZoom; z <= lens.maxZoom; z++) {143 const extension = tileFormat(lens, z);144 if (!extension) continue;145 const window = tileWindowAt(lens, grid, z);146 for (let x = window.x0; x < window.x1; x++) {147 for (let y = window.y0; y < window.y1; y++) {148 if (coverage.has(grid, z, x, y)) names.push(`${z}/${x}/${y}.${extension}`);149 }150 }151 }152 return names;153}