clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat/atlas-next-horizon
atlas / render / chart / projection.ts
projection.ts 4.3K
1// `ATLAS:PIXELS`: the coordinate system the whole seam draws in.2//3// A volume's worlds are cut from one square of world pixels — 8192 across,4// 256 to a tile, in every published bundle — and that square is the5// projection. It has no geodesy in it and wants none: the picture is the6// authority, and a world that also knows where it is on a sphere says so7// through `atlas.geometry.*`, which is the globe's business and not this8// projection's.9//10// origin top-left of the world square11// x increases right12// y **decreases** downward, so the square is [0, −size] … [size, 0]13// resolution world pixels per screen pixel; level z is size / (256 · 2^z)14//15// The banner in the diagnostics object says exactly this, on every report of16// every volume, which makes it the most pinned sentence in the seam.1718import Projection from "ol/proj/Projection.js";19import TileGrid from "ol/tilegrid/TileGrid.js";20import type { Extent } from "ol/extent.js";21import type { Lens, TileGrid as GridSpec } from "../data/payload.ts";2223/** The constant banner. Carried on every diagnostics report; never reworded. */24export const COORDINATE_SYSTEM =25 "ATLAS:PIXELS; origin top-left; x increases right; y decreases downward";2627/** How far past a lens's own deepest level the view may be pushed. */28export const OVERZOOM_LEVELS = 2;2930/** The raster cache each tile source keeps, in tiles. Reported as a constant. */31export const RASTER_CACHE_SIZE = 64;3233/** The world square as an OL extent. */34export function worldExtent(grid: GridSpec): Extent {35 if (grid.extent) return [...grid.extent];36 return [0, -grid.size, grid.size, 0];37}3839/** The projection for one volume's world square. */40export function atlasProjection(grid: GridSpec): Projection {41 return new Projection({42 code: "ATLAS:PIXELS",43 units: "pixels",44 extent: worldExtent(grid),45 });46}4748/** The resolution of pyramid level `z`: world pixels per screen pixel. */49export function levelResolution(grid: GridSpec, z: number): number {50 return grid.size / grid.tileSize / 2 ** z;51}5253/**54 * A tile grid over the world square, from level 0 to `deepest`.55 *56 * The origin is the square's top-left and y decreases downward, which is what57 * makes a tile's `(x, y)` the same pair the archive names its file with. There58 * is no other convention in play anywhere in the format.59 */60export function tileGridFor(grid: GridSpec, deepest: number): TileGrid {61 const resolutions: number[] = [];62 for (let z = 0; z <= deepest; z++) resolutions.push(levelResolution(grid, z));63 return new TileGrid({64 extent: worldExtent(grid),65 origin: [0, 0],66 resolutions,67 tileSize: grid.tileSize,68 });69}7071/**72 * The window a lens draws into, as an OL extent — what the camera fits.73 *74 * A lens may declare two rectangles and they are not interchangeable.75 * `bounds` is the raster window the pyramid actually fills; `surface` is the76 * ground that window pictures, which on a piece of a split sheet is *smaller*77 * than the window, because the window was grown to take in a title drawn78 * beside the map.79 *80 * The camera fits **bounds**, falling back to the whole world square: a81 * reader opening a split sheet is shown everything the lens drew, title82 * included. `surface` is what anything *dividing* the world measures, so no83 * cell lands on margin — and that reading belongs to the analysis lane's84 * Ground descriptor, which is handed both and prefers the other one. A lens85 * that declares a surface and no bounds is the case that tells them apart:86 * the camera opens on the whole square regardless.87 */88export function lensExtent(lens: Lens | null, grid: GridSpec): Extent {89 const rect = lens?.bounds;90 if (!rect) return worldExtent(grid);91 return [rect.x, -(rect.y + rect.height), rect.x + rect.width, -rect.y];92}9394/** The resolution at which an extent just fits a viewport of this size. */95export function fitResolution(extent: Extent, width: number, height: number): number {96 const [minimumX = 0, minimumY = 0, maximumX = 0, maximumY = 0] = extent;97 if (width <= 0 || height <= 0) return 1;98 return Math.max((maximumX - minimumX) / width, (maximumY - minimumY) / height);99}100101/** The deepest zoom the view allows over a lens: its own depth, plus overzoom. */102export function viewMaxZoom(lens: Lens | null): number {103 return (lens?.maxZoom ?? 0) + OVERZOOM_LEVELS;104}