1// The geometry half of the published attribute vocabulary, read by the2// analysis lane.3//4// Issue #5 §3.2 lets this lane depend on "its own math and the published5// attribute vocabulary" and nothing else. `spec/registry.yaml` is that6// vocabulary's one machine-readable source; `./keys.ts` beside this file is7// generated from it, and so is `format/semconv`. This module is the lane's8// *reader* for the four `atlas.geometry.*` keys a cell system can be asked9// about: it never writes a key, never validates a bundle, and knows nothing10// about how a world reached it.11//12// atlas.geometry.surface "sphere" | "plane" (absent means plane)13// atlas.geometry.projection "equirect" | "mercator"14// atlas.geometry.<p>.px x,y,w,h — the raster window, world pixels15// atlas.geometry.<p>.deg west,north,east,south — what it pictures16//17// Coordinates crossing this boundary are **y-down world pixels**: the space18// a packed location lives in, y increasing downward from the world square's19// top-left. The cell systems work in OL world coordinates (y negative-down)20// and flip the sign at the call, which is the one place the two conventions21// meet.2223import {24 KEY_GEOMETRY_EQUIRECT_DEG,25 KEY_GEOMETRY_EQUIRECT_PX,26 KEY_GEOMETRY_MERCATOR_DEG,27 KEY_GEOMETRY_MERCATOR_PX,28 KEY_GEOMETRY_PROJECTION,29 KEY_GEOMETRY_SURFACE,30} from "./keys.ts";3132/** A world's attribute bag: `atlas.*` keys to their string values. */33export type WorldAttrs = Readonly<Record<string, string>>;3435/**36 * The subject of a cell system: one ground you can stand on, carrying the37 * attributes that say what its picture is of. It is the world half of a38 * {@link import("../cellsystems/ground.ts").Ground}.39 */40export interface World {41 readonly attrs: WorldAttrs;42}4344/**45 * A flattening, both ways round. `toLatLng` reads world pixels as true46 * degrees; `toWorld` puts a degree pair back on the picture. The pair is what47 * lets a geodesic system address a raster.48 */49export interface GeoMapping {50 toLatLng(worldX: number, worldY: number): readonly [number, number];51 toWorld(lat: number, lng: number): readonly [number, number];52}5354/** The flattenings this reader knows how to invert. */55type Projection = "equirect" | "mercator";5657/** Each projection's declared window, by the generated key that carries it. */58const WINDOW: Readonly<Record<Projection, { px: string; deg: string }>> = {59 equirect: { px: KEY_GEOMETRY_EQUIRECT_PX, deg: KEY_GEOMETRY_EQUIRECT_DEG },60 mercator: { px: KEY_GEOMETRY_MERCATOR_PX, deg: KEY_GEOMETRY_MERCATOR_DEG },61};6263/**64 * What the world's raster pictures. A world that says nothing is a plane,65 * which every world was until the planets arrived.66 */67export function worldSurface(world: World | null | undefined): string {68 const declared = world?.attrs?.[KEY_GEOMETRY_SURFACE];69 return declared ? declared : "plane";70}7172/**73 * Whichever flattening the world declares — equirectangular or Web-Mercator —74 * answered as the same two functions either way. Null when nothing is75 * declared or the declaration is malformed, which a validated bundle never is.76 */77export function geoMapping(world: World | null | undefined): GeoMapping | null {78 return equirectMapping(world) ?? mercatorMapping(world);79}8081/**82 * The flattening a spherical world declares: the raster window it fills in83 * world pixels and the ground that window pictures in degrees. Only this84 * projection drapes a sphere without resampling, which is why the globe asks85 * for it by name.86 */87export function equirectMapping(world: World | null | undefined): GeoMapping | null {88 const window = declaredWindow(world, "equirect");89 if (!window) return null;90 const [x, y, w, h] = window.px;91 const [west, north, east, south] = window.deg;92 return {93 toLatLng(worldX, worldY) {94 return [95 north - ((worldY - y) / h) * (north - south),96 west + ((worldX - x) / w) * (east - west),97 ];98 },99 toWorld(lat, lng) {100 return [101 x + ((lng - west) / (east - west)) * w,102 y + ((north - lat) / (north - south)) * h,103 ];104 },105 };106}107108/**109 * The Web-Mercator reader: x linear in longitude, y linear in the projected110 * latitude asinh(tan φ), which is what a real-world tile window actually is.111 */112export function mercatorMapping(world: World | null | undefined): GeoMapping | null {113 const window = declaredWindow(world, "mercator");114 if (!window) return null;115 const [x, y, w, h] = window.px;116 const [west, north, east, south] = window.deg;117 const project = (lat: number) => Math.asinh(Math.tan((lat * Math.PI) / 180));118 const unproject = (value: number) => (Math.atan(Math.sinh(value)) * 180) / Math.PI;119 const top = project(north);120 const bottom = project(south);121 return {122 toLatLng(worldX, worldY) {123 return [124 unproject(top - ((worldY - y) / h) * (top - bottom)),125 west + ((worldX - x) / w) * (east - west),126 ];127 },128 toWorld(lat, lng) {129 return [130 x + ((lng - west) / (east - west)) * w,131 y + ((top - project(lat)) / (top - bottom)) * h,132 ];133 },134 };135}136137type Quad = readonly [number, number, number, number];138139/** One declared window: the raster it fills and the ground it pictures. */140export interface ProjectedWindow {141 /** `x, y, w, h` — world pixels, y down. */142 readonly px: Quad;143 /** `west, north, east, south` — degrees at the window's edges. */144 readonly deg: Quad;145}146147/**148 * The Mercator window itself, degrees and all, for the one consumer that149 * needs more than the two functions: an earth-anchored plane's geohash sizes150 * its grid from the window's degree spans (analysis/cellsystems/geohash.ts).151 */152export function mercatorWindow(world: World | null | undefined): ProjectedWindow | null {153 return declaredWindow(world, "mercator");154}155156/**157 * The px/deg pair of one declared projection, or null when the world declares158 * a different projection, none at all, or a pair that cannot be read. A window159 * with no width, no height, or a degenerate degree range is not a mapping:160 * inverting it would divide by zero, and silence beats a plausible NaN.161 *162 * The projection key gates the equirect pair outright — "required when163 * surface is `sphere`", and numbers under an undeclared flattening are164 * numbers this cannot read. The MERCATOR pair alone is also readable when no165 * projection is declared at all: the registry's projection enum names only166 * `equirect`, so a plane georeferenced by a real-world tile window (bend-or)167 * carries `atlas.geometry.mercator.*` with no projection key, and the keys168 * themselves say what the numbers are.169 */170function declaredWindow(171 world: World | null | undefined,172 projection: Projection,173): ProjectedWindow | null {174 const attrs = world?.attrs;175 if (!attrs) return null;176 const declared = attrs[KEY_GEOMETRY_PROJECTION];177 if (declared !== projection && !(projection === "mercator" && declared === undefined)) {178 return null;179 }180 const px = quad(attrs[WINDOW[projection].px]);181 const deg = quad(attrs[WINDOW[projection].deg]);182 if (!px || !deg) return null;183 const [, , w, h] = px;184 const [west, north, east, south] = deg;185 if (!w || !h || north === south || west === east) return null;186 return { px, deg };187}188189/** A comma-separated four-tuple of finite numbers, or null. */190function quad(value: string | undefined): Quad | null {191 const parts = (value ?? "").split(",").map(Number);192 const [a, b, c, d] = parts;193 if (parts.length !== 4) return null;194 if (a === undefined || b === undefined || c === undefined || d === undefined) return null;195 if ([a, b, c, d].some(Number.isNaN)) return null;196 return [a, b, c, d];197}