1// One semantic world, presented once for both panes.2//3// The network boundary is schema + packed vNext tables. This model applies4// the world's replaceable Presentation to its FeatureSets; it never receives5// a v3 document, numeric compatibility identity, or GeoJSON translation.67import type { Ground } from "@atlas/analysis";8import { KEY_GEOMETRY_KIND } from "@atlas/analysis/semconv/keys";9import type { Collection, Lens, TileGrid, WorldPayload } from "../data/payload.ts";10import type { OpenWorld } from "../data/plane.ts";11import type {12 CoordinateSpace, Feature, Geometry, Property, RasterPyramid, Style,13} from "../data/semantic.ts";1415/** `[x, y]` in OL world coordinates: x east, y negative-down. */16export type Coordinate = [number, number];1718/** A closed or open run of coordinates: a ring, or a line. */19export type Line = Coordinate[];2021/** A coordinate-space position enters OpenLayers by flipping y exactly once. */22export function displayPosition(position: readonly [number, number]): Coordinate {23 return [position[0], -position[1]];24}2526/** Geographic coordinates to the native tile-plane space used by producers. */27export function project(grid: TileGrid, lat: number, lng: number): Coordinate {28 const worldTiles = 2 ** grid.sourceZoom;29 const xTile = ((lng + 180) / 360) * worldTiles;30 const yTile = (1 - Math.asinh(Math.tan((lat * Math.PI) / 180)) / Math.PI) / 2 * worldTiles;31 return [32 (xTile - (grid.originX ?? grid.firstTile)) * grid.tileSize,33 -(yTile - (grid.originY ?? grid.firstTile)) * grid.tileSize,34 ];35}3637/** Kept for test fixtures extracted before coordinate spaces became native. */38export function worldGrid(volume: TileGrid, payload: WorldPayload): TileGrid {39 return payload.grid ? { ...volume, ...payload.grid } : volume;40}4142/**43 * The presentation grid for an authoritative vNext coordinate space.44 *45 * Raster-era volumes already declare their square and tile size, and retain46 * those values exactly. A feature-only world has no reason to invent raster47 * metadata, so its finite display scale comes from the coordinate extent and48 * a conventional 256-pixel resolution step. The extent is converted from the49 * semantic space's y-down coordinates to OpenLayers' y-up display space once.50 */51export function presentedGrid(space: CoordinateSpace): TileGrid {52 const [minX, minY, maxX, maxY] = space.extent;53 const width = maxX - minX;54 const height = maxY - minY;55 return {56 sourceZoom: space.sourceZoom,57 originX: space.originX,58 originY: space.originY,59 firstTile: 0,60 tileSize: space.tileSize > 0 ? space.tileSize : 256,61 size: space.size > 0 ? space.size : Math.max(width, height),62 extent: [minX, -maxY, maxX, -minY],63 };64}6566/** One point feature, standing where it stands. */67export interface PointRecord {68 readonly id: string;69 readonly index: number;70 readonly title: string;71 readonly collection: Collection;72 readonly coordinate: Coordinate;73 readonly member: string;74 readonly shard: number;75 readonly feature: Feature;76 readonly priority: number;77}7879/** One path or area feature, already in display coordinates. */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 readonly lines: readonly Line[];88 readonly holes: readonly (readonly Line[])[];89 readonly center: Coordinate | null;90 readonly feature: Feature;91}9293/** The whole of one world, built once from native semantics. */94export class WorldModel {95 readonly points: readonly PointRecord[];96 readonly shapes: readonly ShapeRecord[];97 readonly pointByID: ReadonlyMap<string, PointRecord>;98 readonly shapeByID: ReadonlyMap<string, ShapeRecord>;99 readonly collections: readonly Collection[];100 readonly slug: string;101 readonly title: string;102 readonly lenses: readonly Lens[];103 readonly attrs: Readonly<Record<string, string>>;104 readonly grid: TileGrid;105106 constructor(open: OpenWorld) {107 const { volume, world } = open;108 this.slug = world.id;109 this.title = world.title;110 this.grid = presentedGrid(world.coordinateSpace);111 this.attrs = propertiesToAttrs(world.claims);112 this.lenses = world.rasterPyramids.map(presentRaster);113114 const sets = new Map(world.featureSets.map((set) => [set.id, set]));115 const styles = new Map(world.presentation.styles.map((style) => [style.id, style]));116 const labels = new Map(world.presentation.legend.map((entry) => [entry.layer, entry.label]));117 const collections: Collection[] = [];118 const points: PointRecord[] = [];119 const shapes: ShapeRecord[] = [];120121 for (const layer of world.presentation.layers) {122 const set = sets.get(layer.featureSet);123 const style = styles.get(layer.style);124 if (!set || !style) throw new Error(`layer ${layer.id} has an unresolved feature set or style`);125 // Meaning and shape are separate. A feature set may be semantically a126 // place, trailhead, quest, or anything a schema declares; geometry is127 // read from its native features rather than smuggled through that name.128 const attrs = propertiesToAttrs(set.claims);129 const kind = presentedKind(set.features, set.semanticType, attrs[KEY_GEOMETRY_KIND]);130 const asset = style.iconAsset ? volume.assets.get(style.iconAsset) : undefined;131 const collection: Collection = {132 id: layer.id, featureSet: set.id, style: style.id,133 title: labels.get(layer.id) || set.title, group: layer.group, kind,134 icon: style.icon || style.symbol, iconAsset: asset?.path ?? "", iconPicture: style.iconPicture,135 color: presentedColor(kind, style), visible: layer.visible,136 labelPolicy: layer.labelPolicy, renderAs: style.renderAs,137 attrs,138 };139 collections.push(collection);140 for (const feature of set.features) {141 if (feature.geometry.kind === 1) points.push(pointRecord(feature, collection, points.length));142 else {143 const shape = shapeRecord(feature, collection);144 if (shape) shapes.push(shape);145 }146 }147 }148149 const sizes = new Map<string | number, number>();150 for (const point of points) sizes.set(point.collection.id, (sizes.get(point.collection.id) ?? 0) + 1);151 this.points = points.map((point, index) => ({152 ...point, index, priority: priorityOf(sizes.get(point.collection.id) ?? 0, point.id),153 }));154 this.shapes = shapes;155 this.pointByID = new Map(this.points.map((point) => [point.id, point]));156 this.shapeByID = new Map(shapes.map((shape) => [shape.id, shape]));157 this.collections = collections;158 }159160 feature(id: string): PointRecord | ShapeRecord | null {161 return this.pointByID.get(id) ?? this.shapeByID.get(id) ?? null;162 }163164 ground(lens: Lens | null): Ground {165 return {166 tileGridSize: this.grid.size,167 lens: lens ? { surface: lens.surface ?? null, bounds: lens.bounds ?? null } : null,168 world: { attrs: this.attrs },169 };170 }171}172173export function priorityOf(members: number, id: string): number {174 const rarity = Math.max(0, 1_000_000 - Math.min(members, 999) * 1000);175 return rarity + (stableRank(id) % 1000);176}177178/** FNV-1a over the durable identity; arbitrary strings rank reproducibly. */179export function stableRank(id: string): number {180 let hash = 2166136261 >>> 0;181 for (let i = 0; i < id.length; i++) {182 hash ^= id.charCodeAt(i);183 hash = Math.imul(hash, 16777619) >>> 0;184 }185 return hash >>> 0;186}187188function pointRecord(feature: Feature, collection: Collection, index: number): PointRecord {189 const position = feature.geometry.parts[0]?.rings[0]?.[0];190 if (!position || feature.geometry.parts.length !== 1 || feature.geometry.parts[0]?.rings.length !== 1 ||191 feature.geometry.parts[0]?.rings[0]?.length !== 1) {192 throw new Error(`point ${feature.id} has invalid native geometry`);193 }194 return {195 id: feature.id, index, title: feature.title, collection, coordinate: displayPosition(position),196 member: feature.relationships.find((edge) => edge.predicate === "within")?.target ?? "",197 shard: feature.shard, feature, priority: 0,198 };199}200201function shapeRecord(feature: Feature, collection: Collection): ShapeRecord | null {202 const { lines, holes } = displayGeometry(feature.geometry);203 if (!lines.length) return null;204 return {205 id: feature.id, title: feature.title, subtitle: feature.subtitle, collection,206 kind: feature.geometry.kind === 2 ? "path" : "area", shard: feature.shard,207 lines, holes, center: feature.center ? displayPosition(feature.center) : null, feature,208 };209}210211function displayGeometry(geometry: Geometry): { lines: Line[]; holes: Line[][] } {212 const lines: Line[] = [];213 const holes: Line[][] = [];214 for (const part of geometry.parts) {215 if (geometry.kind === 2) {216 const line = part.rings[0];217 if (part.rings.length !== 1 || !line) continue;218 lines.push(line.map(displayPosition));219 holes.push([]);220 continue;221 }222 if (geometry.kind === 3) {223 const [outer, ...inner] = part.rings;224 if (!outer) continue;225 lines.push(outer.map(displayPosition));226 holes.push(inner.map((ring) => ring.map(displayPosition)));227 }228 }229 return { lines, holes };230}231232type GeometryBearing = { readonly geometry: { readonly kind: number } };233234/** Geometry determines drawing; semantic type remains free to mean something. */235export function presentedKind(236 features: readonly GeometryBearing[],237 semanticType: string,238 declared = "",239): "point" | "path" | "area" {240 const kinds = new Set(features.map((feature) => feature.geometry.kind));241 if (kinds.size > 1) {242 throw new Error(`semantic feature set ${semanticType} mixes geometry kinds`);243 }244 const native = kinds.values().next().value as number | undefined;245 const actual = native === 1 ? "point" : native === 2 ? "path" : native === 3 ? "area" : "";246 if (declared && declared !== "point" && declared !== "path" && declared !== "area") {247 throw new Error(`semantic feature set ${semanticType} declares unknown geometry kind ${declared}`);248 }249 if (declared && actual && declared !== actual) {250 throw new Error(`semantic feature set ${semanticType} declares ${declared} but carries ${actual}`);251 }252 if (declared === "point" || declared === "path" || declared === "area") return declared;253 if (actual) return actual;254255 // An empty feature set has no feature from which to infer its shape. Keep256 // the older geometry.* spelling as a useful declaration for that case.257 const legacyDeclared = semanticType.startsWith("geometry.")258 ? semanticType.slice("geometry.".length)259 : semanticType;260 if (legacyDeclared === "path" || legacyDeclared === "area") return legacyDeclared;261 return "point";262}263264function presentRaster(raster: RasterPyramid): Lens {265 const coverage: Record<string, { x: number; y: number; w: number; h: number; bits: string }> = {};266 for (const level of raster.coverage) {267 coverage[String(level.zoom)] = {268 x: level.x, y: level.y, w: level.w, h: level.h, bits: base64(level.bits),269 };270 }271 const lens: Lens = {272 id: raster.id, name: raster.name, tiles: raster.id, template: raster.template,273 minZoom: raster.minZoom, maxZoom: raster.maxZoom, fullZoom: raster.fullZoom,274 sourceZoom: raster.sourceZoom, formats: raster.formats,275 interpolate: raster.interpolate,276 };277 return {278 ...lens,279 ...(raster.bounds ? { bounds: raster.bounds } : {}),280 ...(raster.surface ? { surface: raster.surface } : {}),281 ...(raster.background ? { background: raster.background } : {}),282 ...(raster.shard ? { shard: raster.shard } : {}),283 ...(Object.keys(coverage).length ? { coverage } : {}),284 };285}286287function presentedColor(kind: string, style: Style): string {288 return kind === "path" && style.stroke ? style.stroke : style.fill;289}290291function propertiesToAttrs(properties: readonly Property[]): Readonly<Record<string, string>> {292 const attrs: Record<string, string> = {};293 for (const property of properties) attrs[property.name] = propertyText(property.value);294 return attrs;295}296297function propertyText(value: Property["value"]): string {298 if (value instanceof Uint8Array) return base64(value);299 return String(value);300}301302function base64(bytes: Uint8Array): string {303 let binary = "";304 for (const byte of bytes) binary += String.fromCharCode(byte);305 return btoa(binary);306}