clone

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

archive/golden-reference
atlas / frontend / src / semconv.js
semconv.js 6.9K
1// The viewer's side of the semantic conventions: the attribute keys a bundle2// may speak, read leniently. An attribute the payload declares wins; a3// payload from before the conventions falls back to the legacy field that4// used to carry the meaning; a key this build has never heard of is simply5// ignored, because a bundle is never refused over vocabulary.67import { collectionFor } from "./collections.js";8import { state } from "./state.js";910// The registry's vocabularies, mirrored: what shape of thing a collection11// holds, and whether names draw unasked.12export const geometryKinds = Object.freeze({ point: "point", path: "path", area: "area" });13export const labelPolicies = Object.freeze({ always: "always", quiet: "quiet" });1415// labelPolicy answers whether a collection's names draw on their own or wait16// to be asked: "always" or "quiet". The reader's per-collection override17// wins, then the collection's declared word, then the kind's own default --18// areas speak unasked, which is what every map before the key already did;19// paths wait; and a point collection speaks when its curation asked for20// text, because floating names are labels a producer pinned on, not a21// different kind of thing.22export function labelPolicy(zone, collection) {23 const declared = collection ?? collectionFor(zone);24 const override = state.labelOverrides.get(declared?.id);25 if (override) return override;26 return curatedLabelPolicy(declared);27}2829// curatedLabelPolicy is the producer's word alone -- what the toggle returns30// to when the reader's override is dropped.31export function curatedLabelPolicy(collection) {32 const curated = collection?.attrs?.["atlas.label.policy"];33 if (curated) return curated;34 if ((collection?.kind ?? geometryKinds.point) === geometryKinds.point) {35 return renderAs(collection) === "text" ? labelPolicies.always : labelPolicies.quiet;36 }37 return collection?.kind === geometryKinds.path ? labelPolicies.quiet : labelPolicies.always;38}3940// labelSilenced says whether the reader themselves quieted a collection's41// names. Z reveals what is merely optional, never what was silenced: the42// choice was the reader's, and the key is not an override.43export function labelSilenced(collection) {44 return state.labelOverrides.get(collection?.id) === labelPolicies.quiet;45}4647// renderAs is the curation's word on a point collection: "pin" or "text".48// Since text became a label policy rather than a way of drawing, this is49// read only as the curated default the policy ladder falls back to --50// markers draw either way.51export function renderAs(collection) {52 return collection?.attrs?.["atlas.render.as"] || "pin";53}5455// The labels the detail card gives a feature's own attributes. Anything in56// the reserved rendering namespaces is machinery rather than material, and57// the geographic pair already has rows of its own.58const attributeLabels = { "atlas.hydro.huc12": "HUC-12" };59const attributeHidden = new Set(["atlas.geo.lat", "atlas.geo.lon"]);60const reservedPrefixes = ["atlas.render.", "atlas.label.", "atlas.stroke.", "atlas.geometry."];6162// featureAttributeRows turns a feature's attributes into the rows its card63// shows: curated label where one exists, the raw key where none does, and64// nothing at all for machinery.65export function featureAttributeRows(attrs) {66 const rows = [];67 for (const [key, value] of Object.entries(attrs || {})) {68 if (attributeHidden.has(key)) continue;69 if (reservedPrefixes.some((prefix) => key.startsWith(prefix))) continue;70 rows.push({ label: attributeLabels[key] || key, value });71 }72 return rows.sort((left, right) => left.label.localeCompare(right.label));73}7475// worldSurface answers what the map's raster pictures. A map that says76// nothing is a plane, which every map was until the planets arrived.77export function worldSurface(map) {78 return map?.attrs?.["atlas.geometry.surface"] || "plane";79}8081// geoMapping reads whichever flattening a map declares -- equirectangular82// or Web-Mercator -- and answers with the same two functions either way:83// world pixels to true degrees and back. Consumers that only need "where84// on the planet is this pixel" ask here; the globe keeps asking for85// equirect specifically, because only that projection drapes a sphere86// without resampling.87export function geoMapping(map) {88 const equirect = equirectMapping(map);89 if (equirect) return equirect;90 return mercatorMapping(map);91}9293// mercatorMapping is the Web-Mercator reader: x linear in longitude, y94// linear in the projected latitude asinh(tan phi), which is what a95// real-world tile window actually is.96export function mercatorMapping(map) {97 const attrs = map?.attrs || {};98 if (attrs["atlas.geometry.projection"] !== "mercator") return null;99 const px = (attrs["atlas.geometry.mercator.px"] || "").split(",").map(Number);100 const deg = (attrs["atlas.geometry.mercator.deg"] || "").split(",").map(Number);101 if (px.length !== 4 || deg.length !== 4 || px.some(Number.isNaN) || deg.some(Number.isNaN)) {102 return null;103 }104 const [x, y, w, h] = px;105 const [west, north, east, south] = deg;106 if (!w || !h || north === south || west === east) return null;107 const project = (lat) => Math.asinh(Math.tan((lat * Math.PI) / 180));108 const unproject = (value) => (Math.atan(Math.sinh(value)) * 180) / Math.PI;109 const top = project(north);110 const bottom = project(south);111 return {112 toLatLng(worldX, worldY) {113 return [114 unproject(top - ((worldY - y) / h) * (top - bottom)),115 west + ((worldX - x) / w) * (east - west),116 ];117 },118 toWorld(lat, lng) {119 return [120 x + ((lng - west) / (east - west)) * w,121 y + ((top - project(lat)) / (top - bottom)) * h,122 ];123 },124 };125}126127// equirectMapping reads the flattening a spherical map declares: the raster128// window it fills in world pixels and the ground that window pictures in129// degrees. It returns an inverter from world pixels back to true latitude130// and longitude -- the whole story any reader needs to stand a packed pin131// on the sphere -- or null when the declaration is absent or malformed,132// which a validated bundle never is.133export function equirectMapping(map) {134 const attrs = map?.attrs || {};135 if (attrs["atlas.geometry.projection"] !== "equirect") return null;136 const px = (attrs["atlas.geometry.equirect.px"] || "").split(",").map(Number);137 const deg = (attrs["atlas.geometry.equirect.deg"] || "").split(",").map(Number);138 if (px.length !== 4 || deg.length !== 4 || px.some(Number.isNaN) || deg.some(Number.isNaN)) {139 return null;140 }141 const [x, y, w, h] = px;142 const [west, north, east, south] = deg;143 if (!w || !h || north === south || west === east) return null;144 return {145 toLatLng(worldX, worldY) {146 return [147 north - ((worldY - y) / h) * (north - south),148 west + ((worldX - x) / w) * (east - west),149 ];150 },151 toWorld(lat, lng) {152 return [153 x + ((lng - west) / (east - west)) * w,154 y + ((north - lat) / (north - south)) * h,155 ];156 },157 };158}