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 { tilePath } from "../data/pyramid.ts";24import { lensExtent } from "./projection.ts";2526const log = logger("overview");2728/** How big the drawn world should be before it is worth reading. */29const TARGET_SIZE = 168;3031/** The mark the sphere's camera leaves: a dot, in whole pixels. */32const MARK_SIZE = 22;3334function clamp(value: number, low: number, high: number): number {35 return Math.min(Math.max(value, low), high);36}3738export class Overview {39 private drawnKey = "";40 /**41 * The canvas the world was drawn onto.42 *43 * A swap can replace the corner's markup — a reconcile re-renders the whole44 * shell — and what comes back is a blank canvas with the same id. Keyed on45 * the world alone, the composite would never be redrawn onto it and the46 * locator would sit over an empty square.47 */48 private drawnCanvas: HTMLCanvasElement | null = null;49 /**50 * The mark the sphere last left, kept so a redraw the chart asked for does51 * not overwrite it. While the globe is up the chart has not moved, so its52 * extent is a stale answer to a question only the sphere can answer.53 */54 private marked: readonly number[] | null = null;5556 private readonly map: OLMap;57 private readonly context: () => WorldContext | null;5859 constructor(map: OLMap, context: () => WorldContext | null) {60 this.map = map;61 this.context = context;62 }6364 /** Forget the drawn world, so the next draw recomposites it. */65 forget(): void {66 this.drawnKey = "";67 }6869 /**70 * Redraw what needs redrawing: the world rarely, the rectangle always.71 *72 * `over` is the camera's own extent when the caller has one the map does73 * not — which is the sphere's case, where the locator is the only written74 * form of the globe's camera and the chart underneath has not moved.75 */76 draw(over?: readonly number[]): void {77 const context = this.context();78 const canvas = document.querySelector<HTMLCanvasElement>("#overview-canvas");79 const box = document.querySelector<HTMLElement>("#overview-viewport");80 if (!context || !context.lens || !canvas || !box) return;81 const extent = lensExtent(context.lens, context.grid);82 const key = `${context.base}/${context.model.slug}/${context.lens.tiles}`;83 if (key !== this.drawnKey || canvas !== this.drawnCanvas) {84 this.drawnKey = key;85 this.drawnCanvas = canvas;86 void this.compose(canvas, context, extent);87 }88 if (over && over.length === 2) this.marked = over;89 this.locate(box, canvas, extent, over ?? this.marked ?? undefined);90 }9192 /** Give the locator back to the chart: the sphere has been put away. */93 release(): void {94 this.marked = null;95 }9697 /**98 * Where the camera is, as a box in whole pixels over the drawn world.99 *100 * `over` of length two is a *point* rather than a window, which is the101 * sphere's case: half a planet is always out of sight, so what the locator102 * marks there is the place the camera faces and not an extent it can see.103 * The mark is a fixed dot -- 22 pixels, the size every recorded globe step104 * carries -- because a rectangle drawn true to a camera hanging over a105 * curved surface says nothing a reader can use.106 *107 * The pixels are the canvas's own, not its CSS box: the composite is108 * written at one device pixel per drawn pixel and the reticle is read back109 * in those, which is what makes "117 53 22 22" reproducible.110 */111 private locate(112 box: HTMLElement,113 canvas: HTMLCanvasElement,114 extent: number[],115 over?: readonly number[],116 ): void {117 const size = this.map.getSize();118 if (!size && !over) return;119 const width = (extent[2] ?? 0) - (extent[0] ?? 0);120 const height = (extent[3] ?? 0) - (extent[1] ?? 0);121 if (!width || !height) return;122 const scaleX = canvas.width / width;123 const scaleY = canvas.height / height;124 if (over && over.length === 2) {125 // On the sphere the locator is always worth having: half a planet is126 // out of sight whatever the camera does.127 const marked = document.querySelector<HTMLElement>("#atlas-overview");128 if (marked) marked.hidden = false;129 const across = clamp(((over[0] ?? 0) - (extent[0] ?? 0)) / width, 0, 1);130 const down = clamp(((extent[3] ?? 0) - (over[1] ?? 0)) / height, 0, 1);131 box.style.left = `${Math.round(across * canvas.width - MARK_SIZE / 2)}px`;132 box.style.top = `${Math.round(down * canvas.height - MARK_SIZE / 2)}px`;133 box.style.width = `${MARK_SIZE}px`;134 box.style.height = `${MARK_SIZE}px`;135 return;136 }137 const camera = over ?? this.map.getView().calculateExtent(size ?? [1, 1]);138 // Hidden while the whole map is on screen: a locator that says "all of139 // it" tells the reader nothing they cannot already see. Fitting the map140 // lands on its extent to within a fraction of a pixel, so the comparison141 // carries four pixels of slack -- an exact one reports the whole map as142 // not quite visible and leaves a locator on screen marking everything.143 const shelf = document.querySelector<HTMLElement>("#atlas-overview");144 if (shelf) {145 const slack = (this.map.getView().getResolution() ?? 0) * 4;146 shelf.hidden =147 (camera[0] ?? 0) <= (extent[0] ?? 0) + slack &&148 (camera[1] ?? 0) <= (extent[1] ?? 0) + slack &&149 (camera[2] ?? 0) >= (extent[2] ?? 0) - slack &&150 (camera[3] ?? 0) >= (extent[3] ?? 0) - slack;151 if (shelf.hidden) return;152 }153 const left = ((camera[0] ?? 0) - (extent[0] ?? 0)) * scaleX;154 const top = ((extent[3] ?? 0) - (camera[3] ?? 0)) * scaleY;155 box.style.left = `${Math.round(left)}px`;156 box.style.top = `${Math.round(top)}px`;157 box.style.width = `${Math.round(((camera[2] ?? 0) - (camera[0] ?? 0)) * scaleX)}px`;158 box.style.height = `${Math.round(((camera[3] ?? 0) - (camera[1] ?? 0)) * scaleY)}px`;159 }160161 /**162 * The world, composited once.163 *164 * The level chosen is the shallowest whose picture of the *lens's own165 * window* is big enough to read — a lens filling a quarter of the world166 * square needs to go two levels deeper for the same number of pixels.167 */168 private async compose(169 canvas: HTMLCanvasElement,170 context: WorldContext,171 extent: number[],172 ): Promise<void> {173 const lens = context.lens;174 if (!lens) return;175 const width = (extent[2] ?? 0) - (extent[0] ?? 0);176 const height = (extent[3] ?? 0) - (extent[1] ?? 0);177 const fraction = Math.max(width, height) / context.grid.size;178 let z = lens.minZoom;179 while (z < lens.fullZoom && context.grid.tileSize * 2 ** z * fraction < TARGET_SIZE) z++;180181 const edge = context.grid.size / 2 ** z;182 const scale = (context.grid.tileSize * 2 ** z) / context.grid.size;183 canvas.width = Math.max(1, Math.round(width * scale));184 canvas.height = Math.max(1, Math.round(height * scale));185 const paper = canvas.getContext("2d");186 if (!paper) return;187 paper.imageSmoothingEnabled = lens.interpolate;188 if (lens.background) {189 paper.fillStyle = lens.background;190 paper.fillRect(0, 0, canvas.width, canvas.height);191 }192 const x0 = Math.floor((extent[0] ?? 0) / edge);193 const x1 = Math.ceil((extent[2] ?? 0) / edge);194 const y0 = Math.floor(-(extent[3] ?? 0) / edge);195 const y1 = Math.ceil(-(extent[1] ?? 0) / edge);196 let drawn = 0;197 for (let x = x0; x < x1; x++) {198 for (let y = y0; y < y1; y++) {199 const url = this.tileURL(context, z, x, y);200 if (!url) continue;201 const image = await load(url);202 if (!image) continue;203 paper.drawImage(204 image,205 (x * edge - (extent[0] ?? 0)) * scale,206 (y * edge + (extent[3] ?? 0)) * scale,207 edge * scale, edge * scale,208 );209 drawn++;210 }211 }212 log.debug("the locator has its world", { op: "render", lens: lens.name, tiles: drawn, z });213 }214215 private tileURL(context: WorldContext, z: number, x: number, y: number): string | null {216 const lens = context.lens;217 if (!lens) return null;218 const extension = lens.formats[z - lens.minZoom];219 if (!extension) return null;220 const path = tilePath(lens, z, x, y);221 return path ? `${context.base}/${path}` : null;222 }223}224225function load(url: string): Promise<HTMLImageElement | null> {226 return new Promise((resolve) => {227 const image = new Image();228 image.onload = () => resolve(image);229 // A tile the locator cannot have is a gap in a thumbnail, not a failure.230 image.onerror = () => resolve(null);231 image.src = url;232 });233}