1// The corner locator: the whole world, and where the camera is on it.2//3// The application renders the shelf, the dock button and the two surfaces —4// `#overview-canvas` and `#overview-viewport` — and marks them5// `hx-morph-skip-children`, because a swap that dropped the canvas would cost6// a redraw of a whole world to say one boolean. Everything inside them is the7// seam's.8//9// The world is drawn ONCE per lens, from the shallowest pyramid level big10// enough to read at the shelf's size, and after that only the rectangle11// moves. That is the whole performance argument: a locator that recomposited12// the world on every camera event would be the most expensive thing on the13// page, and it says exactly as much either way.14//15// The rectangle is written in whole pixels. The diagnostics report carries it16// as `"left top width height"` and sub-pixel drift between two runs is a17// difference that means nothing — the same discipline the camera check18// already used.1920import type OLMap from "ol/Map.js";21import { logger } from "../log.ts";22import type { WorldContext } from "../context.ts";23import { lensExtent } from "./projection.ts";2425const log = logger("overview");2627/** How big the drawn world should be before it is worth reading. */28const TARGET_SIZE = 168;2930/** The mark the sphere's camera leaves: a dot, in whole pixels. */31const MARK_SIZE = 22;3233function clamp(value: number, low: number, high: number): number {34 return Math.min(Math.max(value, low), high);35}3637export class Overview {38 private drawnKey = "";39 /**40 * The canvas the world was drawn onto.41 *42 * A swap can replace the corner's markup — a reconcile re-renders the whole43 * shell — and what comes back is a blank canvas with the same id. Keyed on44 * the world alone, the composite would never be redrawn onto it and the45 * locator would sit over an empty square.46 */47 private drawnCanvas: HTMLCanvasElement | null = null;48 /**49 * The mark the sphere last left, kept so a redraw the chart asked for does50 * not overwrite it. While the globe is up the chart has not moved, so its51 * extent is a stale answer to a question only the sphere can answer.52 */53 private marked: readonly number[] | null = null;5455 private readonly map: OLMap;56 private readonly context: () => WorldContext | null;5758 constructor(map: OLMap, context: () => WorldContext | null) {59 this.map = map;60 this.context = context;61 }6263 /** Forget the drawn world, so the next draw recomposites it. */64 forget(): void {65 this.drawnKey = "";66 }6768 /**69 * Redraw what needs redrawing: the world rarely, the rectangle always.70 *71 * `over` is the camera's own extent when the caller has one the map does72 * not — which is the sphere's case, where the locator is the only written73 * form of the globe's camera and the chart underneath has not moved.74 */75 draw(over?: readonly number[]): void {76 const context = this.context();77 const canvas = document.querySelector<HTMLCanvasElement>("#overview-canvas");78 const box = document.querySelector<HTMLElement>("#overview-viewport");79 if (!context || !context.lens || !canvas || !box) return;80 const extent = lensExtent(context.lens, context.grid);81 const key = `${context.base}/${context.model.slug}/${context.lens.tiles}`;82 if (key !== this.drawnKey || canvas !== this.drawnCanvas) {83 this.drawnKey = key;84 this.drawnCanvas = canvas;85 void this.compose(canvas, context, extent);86 }87 if (over && over.length === 2) this.marked = over;88 this.locate(box, canvas, extent, over ?? this.marked ?? undefined);89 }9091 /** Give the locator back to the chart: the sphere has been put away. */92 release(): void {93 this.marked = null;94 }9596 /**97 * Where the camera is, as a box in whole pixels over the drawn world.98 *99 * `over` of length two is a *point* rather than a window, which is the100 * sphere's case: half a planet is always out of sight, so what the locator101 * marks there is the place the camera faces and not an extent it can see.102 * The mark is a fixed dot -- 22 pixels, the size every recorded globe step103 * carries -- because a rectangle drawn true to a camera hanging over a104 * curved surface says nothing a reader can use.105 *106 * The pixels are the canvas's own, not its CSS box: the composite is107 * written at one device pixel per drawn pixel and the reticle is read back108 * in those, which is what makes "117 53 22 22" reproducible.109 */110 private locate(111 box: HTMLElement,112 canvas: HTMLCanvasElement,113 extent: number[],114 over?: readonly number[],115 ): void {116 const size = this.map.getSize();117 if (!size && !over) return;118 const width = (extent[2] ?? 0) - (extent[0] ?? 0);119 const height = (extent[3] ?? 0) - (extent[1] ?? 0);120 if (!width || !height) return;121 const scaleX = canvas.width / width;122 const scaleY = canvas.height / height;123 if (over && over.length === 2) {124 // On the sphere the locator is always worth having: half a planet is125 // out of sight whatever the camera does.126 const marked = document.querySelector<HTMLElement>("#atlas-overview");127 if (marked) marked.hidden = false;128 const across = clamp(((over[0] ?? 0) - (extent[0] ?? 0)) / width, 0, 1);129 const down = clamp(((extent[3] ?? 0) - (over[1] ?? 0)) / height, 0, 1);130 box.style.left = `${Math.round(across * canvas.width - MARK_SIZE / 2)}px`;131 box.style.top = `${Math.round(down * canvas.height - MARK_SIZE / 2)}px`;132 box.style.width = `${MARK_SIZE}px`;133 box.style.height = `${MARK_SIZE}px`;134 return;135 }136 const camera = over ?? this.map.getView().calculateExtent(size ?? [1, 1]);137 // Hidden while the whole map is on screen: a locator that says "all of138 // it" tells the reader nothing they cannot already see. Fitting the map139 // lands on its extent to within a fraction of a pixel, so the comparison140 // carries four pixels of slack -- an exact one reports the whole map as141 // not quite visible and leaves a locator on screen marking everything.142 const shelf = document.querySelector<HTMLElement>("#atlas-overview");143 if (shelf) {144 const slack = (this.map.getView().getResolution() ?? 0) * 4;145 shelf.hidden =146 (camera[0] ?? 0) <= (extent[0] ?? 0) + slack &&147 (camera[1] ?? 0) <= (extent[1] ?? 0) + slack &&148 (camera[2] ?? 0) >= (extent[2] ?? 0) - slack &&149 (camera[3] ?? 0) >= (extent[3] ?? 0) - slack;150 if (shelf.hidden) return;151 }152 const left = ((camera[0] ?? 0) - (extent[0] ?? 0)) * scaleX;153 const top = ((extent[3] ?? 0) - (camera[3] ?? 0)) * scaleY;154 box.style.left = `${Math.round(left)}px`;155 box.style.top = `${Math.round(top)}px`;156 box.style.width = `${Math.round(((camera[2] ?? 0) - (camera[0] ?? 0)) * scaleX)}px`;157 box.style.height = `${Math.round(((camera[3] ?? 0) - (camera[1] ?? 0)) * scaleY)}px`;158 }159160 /**161 * The world, composited once.162 *163 * The level chosen is the shallowest whose picture of the *lens's own164 * window* is big enough to read — a lens filling a quarter of the world165 * square needs to go two levels deeper for the same number of pixels.166 */167 private async compose(168 canvas: HTMLCanvasElement,169 context: WorldContext,170 extent: number[],171 ): Promise<void> {172 const lens = context.lens;173 if (!lens) return;174 const width = (extent[2] ?? 0) - (extent[0] ?? 0);175 const height = (extent[3] ?? 0) - (extent[1] ?? 0);176 const fraction = Math.max(width, height) / context.grid.size;177 let z = lens.minZoom;178 while (z < lens.fullZoom && context.grid.tileSize * 2 ** z * fraction < TARGET_SIZE) z++;179180 const edge = context.grid.size / 2 ** z;181 const scale = (context.grid.tileSize * 2 ** z) / context.grid.size;182 canvas.width = Math.max(1, Math.round(width * scale));183 canvas.height = Math.max(1, Math.round(height * scale));184 const paper = canvas.getContext("2d");185 if (!paper) return;186 paper.imageSmoothingEnabled = lens.interpolate;187 if (lens.background) {188 paper.fillStyle = lens.background;189 paper.fillRect(0, 0, canvas.width, canvas.height);190 }191 const x0 = Math.floor((extent[0] ?? 0) / edge);192 const x1 = Math.ceil((extent[2] ?? 0) / edge);193 const y0 = Math.floor(-(extent[3] ?? 0) / edge);194 const y1 = Math.ceil(-(extent[1] ?? 0) / edge);195 let drawn = 0;196 for (let x = x0; x < x1; x++) {197 for (let y = y0; y < y1; y++) {198 const url = this.tileURL(context, z, x, y);199 if (!url) continue;200 const image = await load(url);201 if (!image) continue;202 paper.drawImage(203 image,204 (x * edge - (extent[0] ?? 0)) * scale,205 (y * edge + (extent[3] ?? 0)) * scale,206 edge * scale, edge * scale,207 );208 drawn++;209 }210 }211 log.debug("the locator has its world", { op: "render", lens: lens.name, tiles: drawn, z });212 }213214 private tileURL(context: WorldContext, z: number, x: number, y: number): string | null {215 const lens = context.lens;216 if (!lens) return null;217 const extension = lens.formats[z - lens.minZoom];218 if (!extension) return null;219 return `${context.base}/tiles/${lens.tiles}/${z}/${x}/${y}.${extension}`;220 }221}222223function load(url: string): Promise<HTMLImageElement | null> {224 return new Promise((resolve) => {225 const image = new Image();226 image.onload = () => resolve(image);227 // A tile the locator cannot have is a gap in a thumbnail, not a failure.228 image.onerror = () => resolve(null);229 image.src = url;230 });231}