1// The raster: two layers, because a pyramid is two different things.2//3// Up to `fullZoom` a pyramid is COMPLETE — every tile of every level exists,4// and the layer drawing them can always be stretched over a deeper view.5// Above it the capture only reached where it reached, and the coverage6// bitsets say where. Those patches ride on a second layer above the first, so7// the complete pyramid still shows through wherever the deep capture has a8// gap, and the reader never sees a hole where there is ground.9//10// Two overzoom levels sit past the deepest tiles either layer holds. There11// the base layer's own tiles are drawn larger — parent upsampling, which OL12// does for a tile grid that stops — and the lens's `interpolate` decides how:13// smooth for a photograph, nearest-neighbour for pixel art, because a14// hand-drawn map magnified with bilinear smoothing stops being a drawing.15//16// Both layers are clipped to the window the lens fills, and the base carries17// the colour the sheet was drawn on. Those are the lens's own declarations,18// and the corner locator already honours the second one — a map that dropped19// either disagreed with the shelf beside it about what one lens looks like.20//21// Nothing here asks for a tile the coverage denies. A wasted request is not22// free: it is a 404 the browser logs, a slot in the queue, and a reader23// watching a spinner for a tile that was never written.2425import TileLayer from "ol/layer/Tile.js";26import XYZ from "ol/source/XYZ.js";27import type Projection from "ol/proj/Projection.js";28import { logger } from "../log.ts";29import type { DataPlane } from "../data/plane.ts";30import type { Lens, TileGrid as GridSpec } from "../data/payload.ts";31import { LensCoverage } from "../data/pyramid.ts";32import { RASTER_CACHE_SIZE, lensExtent, levelResolution, tileGridFor } from "./projection.ts";3334const log = logger("raster");3536/** Tiles since the lens was chosen. Two of the four are route, not destination. */37export interface TileStats {38 requested: number;39 loaded: number;40 errors: number;41 peakPending: number;42}4344/** A counter the two sources share, so the pair reports as one pyramid. */45export class TileCounter {46 readonly stats: TileStats = { requested: 0, loaded: 0, errors: 0, peakPending: 0 };47 private pending = 0;4849 reset(): void {50 this.stats.requested = 0;51 this.stats.loaded = 0;52 this.stats.errors = 0;53 this.stats.peakPending = 0;54 this.pending = 0;55 }5657 started(): void {58 this.stats.requested++;59 this.pending++;60 if (this.pending > this.stats.peakPending) this.stats.peakPending = this.pending;61 }6263 finished(): void {64 this.stats.loaded++;65 this.pending = Math.max(0, this.pending - 1);66 }6768 failed(): void {69 this.stats.errors++;70 this.pending = Math.max(0, this.pending - 1);71 }72}7374/** The two raster layers of one lens, and the counter they share. */75export interface Raster {76 readonly base: TileLayer<XYZ>;77 readonly detail: TileLayer<XYZ>;78}7980function source(81 plane: DataPlane,82 base: string,83 lens: Lens,84 grid: GridSpec,85 projection: Projection,86 deepest: number,87 coverage: LensCoverage,88 counter: TileCounter,89): XYZ {90 const xyz = new XYZ({91 projection,92 tileGrid: tileGridFor(grid, deepest),93 interpolate: lens.interpolate,94 wrapX: false,95 cacheSize: RASTER_CACHE_SIZE,96 transition: 0,97 tileUrlFunction: (coordinate) => {98 const [z, x, y] = coordinate as [number, number, number];99 if (!coverage.has(grid, z, x, y)) return undefined;100 return plane.tileURL(base, lens, z, x, y) ?? undefined;101 },102 });103 xyz.on("tileloadstart", () => counter.started());104 xyz.on("tileloadend", () => counter.finished());105 xyz.on("tileloaderror", () => {106 counter.failed();107 log.warn("a tile the coverage admitted did not arrive", { op: "render", lens: lens.name });108 });109 return xyz;110}111112/**113 * Build the pair for one lens.114 *115 * The base carries levels `minZoom … fullZoom`, the detail carries everything116 * above it. A pyramid that is complete to its own bottom gets an empty detail117 * layer rather than a special case: one shape of code, and one fewer thing118 * that can be wrong on the volumes where it matters.119 */120export function buildRaster(121 plane: DataPlane,122 base: string,123 lens: Lens,124 grid: GridSpec,125 projection: Projection,126 counter: TileCounter,127): Raster {128 const coverage = new LensCoverage(lens);129 const complete = Math.max(lens.minZoom, Math.min(lens.fullZoom, lens.maxZoom));130 const baseSource = source(plane, base, lens, grid, projection, complete, coverage, counter);131 const detailSource = source(plane, base, lens, grid, projection, lens.maxZoom, coverage, counter);132 log.info("a lens is open", {133 op: "render", lens: lens.name, path: lens.tiles,134 complete, deepest: lens.maxZoom, interpolate: lens.interpolate,135 });136 // The window the lens fills, and the colour behind it. Both are the lens's137 // own declarations and both were already being honoured elsewhere -- the138 // corner locator paints `background` under its thumbnail -- so a map that139 // ignored them disagreed with the shelf beside it about what the same lens140 // looks like. `extent` clips both layers to the window: coverage and141 // `wrapX: false` already refuse most requests outside it, but a layer142 // nobody clipped still *draws* out there, and on a sparse pyramid or a143 // piece of a split sheet that is a hole, or the neighbouring layer's144 // ground, in the margin.145 const window = lensExtent(lens, grid);146 return {147 // Only the base carries the backdrop. The detail layer rides above it, so148 // a background there would paint over the complete pyramid wherever the149 // deep capture has a gap -- which is the one thing the pair exists to150 // prevent.151 base: new TileLayer({152 source: baseSource,153 zIndex: 0,154 extent: window,155 background: lens.background || undefined,156 }),157 // Levels above the complete one are only captured in patches. They ride158 // on top of the base so the fully-covered pyramid still shows through159 // wherever the deep capture has a gap.160 // The detail layer draws only where it has something the base does not:161 // strictly deeper than the complete level. Above that threshold the base162 // pyramid is whole and asking the patchy one for the same ground would163 // double every request a fresh view makes -- a number the diagnostics164 // report carries, so it is a correctness question and not a saving.165 // A pyramid complete to its own bottom says so with a layer that never166 // renders, rather than with a special case.167 detail: new TileLayer({168 source: detailSource,169 zIndex: 1,170 extent: window,171 maxResolution: lens.maxZoom > complete ? levelResolution(grid, complete) : 0,172 }),173 };174}