1// The world as the seam holds it: every feature, in world coordinates, once.2//3// Three payloads arrive from `/data` and one model comes out of them. The4// model is built once per world and is immutable afterwards: filters, grids5// and selections are questions asked *of* it (`visibility.ts`), never edits6// to it, which is what lets the chart and the globe read the same registry7// and answer the same counts.8//9// TWO CONVENTIONS MEET HERE, and both are load-bearing.10//11// A payload speaks **y-down world pixels** and spells positions as12// `lat`/`lng` in the volume's own world space — which is the slippy-tile13// grid the capture was cut from, not WGS 84. `project` is that conversion,14// and it is the same arithmetic the application does server-side15// (`internal/app/world.go`). It is **not** written down in `docs/format.md`,16// which is a gap in the format spec rather than a decision of this lane; it17// is written down in `docs/render-seam.md` §3 until it moves where it18// belongs.19//20// Everything downstream — OpenLayers, the analysis lane's cell systems, the21// grid, the overview — speaks **OL world coordinates**: x east, y22// negative-down. The sign flips exactly once, here.2324import type { Ground } from "@atlas/analysis";25import type { LocationTable } from "../data/atlasloc.ts";26import type {27 Collection, Geometry, Lens, ShapeFeature, TileGrid, WorldPayload,28} from "../data/payload.ts";2930/** `[x, y]` in OL world coordinates: x east, y negative-down. */31export type Coordinate = [number, number];3233/** A closed or open run of coordinates: a ring, or a line. */34export type Line = Coordinate[];3536/**37 * Where a `lat`/`lng` pair lands on the world square.38 *39 * A pin arrives as a latitude and longitude in the source tile grid, which is40 * not the same grid for every volume: most worlds are cut from a 32-tile41 * square at zoom 13, and a world cut from a window of its own carries where42 * that window sits (format.md §6.2). Both describe the same world square, so43 * everything downstream works in world units either way.44 */45export function project(grid: TileGrid, lat: number, lng: number): Coordinate {46 const worldTiles = 2 ** grid.sourceZoom;47 const xTile = ((lng + 180) / 360) * worldTiles;48 const yTile = (1 - Math.asinh(Math.tan((lat * Math.PI) / 180)) / Math.PI) / 2 * worldTiles;49 return [50 (xTile - grid.firstTile) * grid.tileSize,51 -(yTile - grid.firstTile) * grid.tileSize,52 ];53}5455/** The volume's grid, with the world's own window override applied. */56export function worldGrid(volume: TileGrid, payload: WorldPayload): TileGrid {57 return payload.grid ? { ...volume, ...payload.grid } : volume;58}5960/** One point feature, standing where it stands. */61export interface PointRecord {62 readonly id: string;63 readonly index: number;64 readonly title: string;65 readonly collection: Collection;66 readonly coordinate: Coordinate;67 readonly member: number;68 readonly shard: number;69 /**70 * The order this pin takes its place in when pins overlap: rarer71 * collections first, ties broken by a stable hash of the id so two runs72 * draw the crowd in the same order. Also the declutter priority — a label73 * that has to give way gives way to a rarer one, never to whichever74 * happened to be built first.75 */76 readonly priority: number;77}7879/** One path or area feature, projected. */80export interface ShapeRecord {81 readonly id: string;82 readonly title: string;83 readonly subtitle: string;84 readonly collection: Collection;85 readonly kind: "path" | "area";86 readonly shard: number;87 /** Outer rings (areas) or lines (paths), already in world coordinates. */88 readonly lines: readonly Line[];89 /** Inner rings, per outer ring, for areas that carry holes. */90 readonly holes: readonly (readonly Line[])[];91 readonly center: Coordinate | null;92 readonly feature: ShapeFeature;93}9495/** The whole of one world, built once. */96export class WorldModel {97 readonly points: readonly PointRecord[];98 readonly shapes: readonly ShapeRecord[];99 readonly pointByID: ReadonlyMap<string, PointRecord>;100 readonly shapeByID: ReadonlyMap<string, ShapeRecord>;101 readonly collections: readonly Collection[];102103 readonly slug: string;104 readonly payload: WorldPayload;105 readonly grid: TileGrid;106107 constructor(108 slug: string,109 payload: WorldPayload,110 grid: TileGrid,111 table: LocationTable | null,112 ) {113 this.slug = slug;114 this.payload = payload;115 this.grid = grid;116 this.collections = payload.collections;117 const points: PointRecord[] = [];118 const pointByID = new Map<string, PointRecord>();119 if (table) {120 // The packed payload's `owner` indexes the collections array, which is121 // the only collection identity a reader needs and the whole reason that122 // array's order is significant (format.md §7.1).123 const sizes = new Map<number, number>();124 for (let i = 0; i < table.count; i++) {125 const owner = table.owner[i] ?? 0;126 sizes.set(owner, (sizes.get(owner) ?? 0) + 1);127 }128 for (let i = 0; i < table.count; i++) {129 const owner = table.owner[i] ?? 0;130 const collection = payload.collections[owner];131 if (!collection || collection.kind !== "point") continue;132 const id = String(table.id[i] ?? 0);133 const record: PointRecord = {134 id,135 index: i,136 title: table.title(i),137 collection,138 coordinate: project(grid, table.lat[i] ?? 0, table.lng[i] ?? 0),139 member: table.member[i] ?? 0,140 shard: table.shard[i] ?? 0,141 priority: priorityOf(sizes.get(owner) ?? 0, table.id[i] ?? 0),142 };143 points.push(record);144 pointByID.set(id, record);145 }146 }147 this.points = points;148 this.pointByID = pointByID;149150 const shapes: ShapeRecord[] = [];151 const shapeByID = new Map<string, ShapeRecord>();152 for (const collection of payload.collections) {153 if (collection.kind === "point") continue;154 for (const feature of collection.features ?? []) {155 const record = shapeRecord(grid, collection, feature);156 if (!record) continue;157 shapes.push(record);158 shapeByID.set(record.id, record);159 }160 }161 this.shapes = shapes;162 this.shapeByID = shapeByID;163 }164165 /** Either kind of feature, by the id the application spells it with. */166 feature(id: string): PointRecord | ShapeRecord | null {167 return this.pointByID.get(id) ?? this.shapeByID.get(id) ?? null;168 }169170 /** The ground a cell system divides: the analysis lane's own descriptor. */171 ground(lens: Lens | null): Ground {172 return {173 tileGridSize: this.grid.size,174 lens: lens ? { surface: lens.surface ?? null, bounds: lens.bounds ?? null } : null,175 world: { attrs: this.payload.attrs ?? {} },176 };177 }178}179180/**181 * A pin's place in the crowd.182 *183 * Rarity first — a collection with three members is what a reader came to184 * find, and one with nine hundred is the texture behind it — then a stable185 * hash of the feature's own id, so a tie is broken the same way on every run186 * and in every pane.187 */188export function priorityOf(members: number, id: number): number {189 const rarity = Math.max(0, 1_000_000 - Math.min(members, 999) * 1000);190 return rarity + (stableRank(id) % 1000);191}192193/** A small deterministic hash: the same id ranks the same way forever. */194export function stableRank(id: number): number {195 let hash = 2166136261 >>> 0;196 const text = String(id);197 for (let i = 0; i < text.length; i++) {198 hash ^= text.charCodeAt(i);199 hash = Math.imul(hash, 16777619) >>> 0;200 }201 return hash >>> 0;202}203204function shapeRecord(205 grid: TileGrid,206 collection: Collection,207 feature: ShapeFeature,208): ShapeRecord | null {209 const lines: Line[] = [];210 const holes: Line[][] = [];211 for (const geometry of feature.geometry ?? []) {212 for (const { outer, inner } of readGeometry(grid, geometry)) {213 lines.push(outer);214 holes.push(inner);215 }216 }217 if (!lines.length) return null;218 return {219 id: String(feature.id),220 title: feature.title ?? "",221 subtitle: feature.subtitle ?? "",222 collection,223 kind: collection.kind === "path" ? "path" : "area",224 shard: feature.shard ?? 0,225 lines,226 holes,227 center: feature.center ? project(grid, feature.center.lat, feature.center.lng) : null,228 feature,229 };230}231232type Piece = { outer: Line; inner: Line[] };233234/**235 * GeoJSON-shaped geometry, projected into world coordinates.236 *237 * Coordinate pairs are `[lng, lat]` as GeoJSON writes them, in the volume's238 * own world space. Types are read leniently: a geometry whose type does not239 * fit its collection's kind was refused at build time (format.md §9.3), so a240 * type this reader does not know is a newer producer, not a broken bundle.241 */242function readGeometry(grid: TileGrid, geometry: Geometry): Piece[] {243 const point = (pair: unknown): Coordinate => {244 const [lng, lat] = pair as [number, number];245 return project(grid, lat, lng);246 };247 const line = (raw: unknown): Line => (raw as unknown[]).map(point);248 const polygon = (raw: unknown): Piece => {249 const rings = (raw as unknown[]).map(line);250 const [outer, ...inner] = rings;251 return { outer: outer ?? [], inner };252 };253 switch (geometry.type) {254 case "Polygon":255 return [polygon(geometry.coordinates)];256 case "MultiPolygon":257 return (geometry.coordinates as unknown[]).map(polygon);258 case "LineString":259 return [{ outer: line(geometry.coordinates), inner: [] }];260 case "MultiLineString":261 return (geometry.coordinates as unknown[]).map((raw) => ({ outer: line(raw), inner: [] }));262 default:263 return [];264 }265}