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 / internal / semconv / semconv.go
semconv.go 14.9K
1// Package semconv is the registry of Atlas's semantic conventions: the2// attribute keys that carry display and geometry meaning through the3// pipeline as a shared language, the way OpenTelemetry's semantic4// conventions carry meaning between systems that never met.5//6// The conventions are Atlas's own. They exist precisely so the format is7// beholden to no upstream's habits -- not MapGenie's category grammar, not8// the games' assumptions, not the sciences' tiling -- and every rule that9// used to be an unspoken promotion inside one tool becomes a named key any10// producer can write and any reader can act on.11//12// The contract has two sides, deliberately asymmetric. Producers are strict:13// an attribute in the atlas namespace that the registry does not know, or a14// value outside its vocabulary, fails the build while the mistake is one15// change old. Readers are lenient: an unknown key is ignored, never refused,16// so a bundle written by a newer pipeline still opens in an older app. New17// keys arrive with experimental stability and earn stable; only a breaking18// change to an existing vocabulary would move Version.19package semconv2021import (22 "fmt"23 "slices"24 "sort"25 "strconv"26 "strings"27)2829// Version names the vocabulary a bundle was written against. It rides the30// manifest as "conventions" and moves only when an existing key's meaning31// breaks -- additions ride on per-key stability instead. v2 collapsed the32// zone/category/location entities into collection/feature when the format33// unified them: every kind of thing on a map is a feature, every named set34// of them a collection.35const Version = 23637// Entity is what an attribute attaches to.38type Entity string3940const (41 EntityBundle Entity = "bundle"42 EntityWorld Entity = "world"43 EntityCollection Entity = "collection"44 EntityFeature Entity = "feature"45)4647// Stability says how settled a key is. Experimental keys may still change48// spelling or vocabulary; stable keys only break with a Version move.49type Stability string5051const (52 Stable Stability = "stable"53 Experimental Stability = "experimental"54)5556// The registered keys.57const (58 // GeometryKind says what shape of thing a collection holds: points,59 // paths, or areas. Every collection declares one kind and every feature60 // in it is that kind; readers pick their rendering and UX by this key61 // instead of sniffing geometry types at draw time.62 KeyGeometryKind = "atlas.geometry.kind"6364 // LabelPolicy says whether an area collection's features wear their65 // names on the map always, or quietly -- only on highlight, selection,66 // or an explicit reveal. It attaches to area collections only: absent67 // means always for areas, which is today's look, and paths are always68 // quiet.69 KeyLabelPolicy = "atlas.label.policy"7071 // RenderAs says how a point collection's features are drawn: as markers72 // or as floating text labels. It replaces the pipeline's single unspoken73 // display rule, the string compare on MapGenie's display_type.74 KeyRenderAs = "atlas.render.as"7576 // IconStd names a standard-library icon for a collection that has no77 // artwork of its own, as set/name, e.g. "maki/mountain". The ingestion78 // pipeline resolves it to embedded bytes; the app only ever sees the79 // resolved asset.80 KeyIconStd = "atlas.icon.std"8182 // IconKind says whether a collection's icon asset is a monochrome glyph83 // the viewer tints, or a picture drawn as-is. It names what used to be84 // inferred from the asset's file extension.85 KeyIconKind = "atlas.icon.kind"8687 // IconOutset says which rim a map's markers wear so they stay legible88 // against its art: the light rim of a dark map or the dark rim of a89 // light one.90 KeyIconOutset = "atlas.icon.outset"9192 // GeometrySurface declares what the map's raster is a picture of: a93 // plane, which every map was until a planet arrived, or a sphere. A map94 // that says nothing is a plane.95 KeyGeometrySurface = "atlas.geometry.surface"9697 // GeometryProjection says how a spherical surface was flattened into98 // the raster. Equirectangular is the only vocabulary so far.99 KeyGeometryProjection = "atlas.geometry.projection"100101 // GeometryEquirectPx is the raster window the projection fills, in102 // world pixels, as "x,y,w,h".103 KeyGeometryEquirectPx = "atlas.geometry.equirect.px"104105 // GeometryEquirectDeg is the ground that window pictures, in degrees,106 // as "west,north,east,south".107 KeyGeometryEquirectDeg = "atlas.geometry.equirect.deg"108109 // GeometryMercatorPx is the raster window a Web-Mercator cut fills, in110 // world pixels, as "x,y,w,h". Where equirect declares y linear in111 // degrees, mercator declares y linear in the projected latitude --112 // asinh(tan latitude) -- which is the flattening a real-world tile113 // window actually is.114 KeyGeometryMercatorPx = "atlas.geometry.mercator.px"115116 // GeometryMercatorDeg is the ground that window pictures, in degrees at117 // the window's edges, as "west,north,east,south".118 KeyGeometryMercatorDeg = "atlas.geometry.mercator.deg"119120 // GeometryBody names the body pictured, e.g. "mars".121 KeyGeometryBody = "atlas.geometry.body"122123 // GeometryRadiusKM is the body's mean radius in kilometers, as a124 // decimal string.125 KeyGeometryRadiusKM = "atlas.geometry.radius_km"126127 // GeoLat and GeoLon carry a feature's true planetary coordinates as128 // the source published them: planetocentric degrees, east-positive129 // longitude. They are provenance and card material; rendering derives130 // positions from the map-level mapping instead.131 KeyGeoLat = "atlas.geo.lat"132 KeyGeoLon = "atlas.geo.lon"133134 // HydroHUC12 names the USGS twelve-digit hydrologic unit -- the135 // subwatershed -- a feature's ground lies wholly within, from the136 // national Watershed Boundary Dataset, so watershed membership is137 // machine-readable beside whatever sentence the feature's card shows. A138 // feature spanning subwatersheds carries no key rather than a misleading139 // one.140 KeyHydroHUC12 = "atlas.hydro.huc12"141142 // StrokeWidthPx is the ground width of a path collection's features, in143 // world pixels: a trail is a line and a weight, and declaring the weight144 // lets a reader draw the path as one continuous stroke instead of an145 // area faked around it.146 KeyStrokeWidthPx = "atlas.stroke.width_px"147148 // CollectionKey is a collection's merge identity: the slug collections149 // from different sources meet under when they mean the same concept.150 // Absent, the icon key stands in, which is today's behavior named.151 KeyCollectionKey = "atlas.collection.key"152153 // NoteText never appears in a payload: it is the name the merge policy154 // table and ledger use for a pin's description, so "which description155 // wins" is decided and recorded in the same vocabulary as everything156 // else.157 KeyNoteText = "atlas.note.text"158)159160// Vocabulary values.161const (162 GeometryPoint = "point"163 GeometryPath = "path"164 GeometryArea = "area"165166 LabelAlways = "always"167 LabelQuiet = "quiet"168169 RenderAsPin = "pin"170 RenderAsText = "text"171172 IconKindGlyph = "glyph"173 IconKindPicture = "picture"174175 OutsetLight = "light"176 OutsetDark = "dark"177178 SurfacePlane = "plane"179 SurfaceSphere = "sphere"180181 ProjectionEquirect = "equirect"182)183184// definition is one registered key: where it attaches, how settled it is,185// and what values it admits.186type definition struct {187 entity Entity188 stability Stability189 check func(value string) error190}191192func enum(values ...string) func(string) error {193 return func(value string) error {194 if slices.Contains(values, value) {195 return nil196 }197 return fmt.Errorf("%q is not one of %s", value, strings.Join(values, "|"))198 }199}200201func slug(value string) error {202 if value == "" {203 return fmt.Errorf("empty")204 }205 for _, r := range value {206 if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' {207 continue208 }209 return fmt.Errorf("%q is not a slug", value)210 }211 return nil212}213214func decimal(value string) error {215 if _, err := strconv.ParseFloat(value, 64); err != nil {216 return fmt.Errorf("%q is not a number", value)217 }218 return nil219}220221func positiveDecimal(value string) error {222 parsed, err := strconv.ParseFloat(value, 64)223 if err != nil || parsed <= 0 {224 return fmt.Errorf("%q is not a positive number", value)225 }226 return nil227}228229func huc12(value string) error {230 if len(value) != 12 {231 return fmt.Errorf("%q is not twelve digits", value)232 }233 for _, r := range value {234 if r < '0' || r > '9' {235 return fmt.Errorf("%q is not twelve digits", value)236 }237 }238 return nil239}240241func setName(value string) error {242 set, name, found := strings.Cut(value, "/")243 if !found || set == "" || name == "" {244 return fmt.Errorf("%q is not set/name", value)245 }246 if err := slug(set); err != nil {247 return err248 }249 return slug(name)250}251252func numbers(count int) func(string) error {253 return func(value string) error {254 parts := strings.Split(value, ",")255 if len(parts) != count {256 return fmt.Errorf("%q wants %d comma-separated numbers", value, count)257 }258 for _, part := range parts {259 if _, err := strconv.ParseFloat(part, 64); err != nil {260 return fmt.Errorf("%q is not a number", part)261 }262 }263 return nil264 }265}266267// registry is the whole vocabulary. REGISTRY.md is its prose twin, and a268// test holds the two to the same list of keys.269var registry = map[string]definition{270 KeyGeometryKind: {EntityCollection, Stable, enum(GeometryPoint, GeometryPath, GeometryArea)},271 KeyLabelPolicy: {EntityCollection, Experimental, enum(LabelAlways, LabelQuiet)},272 KeyRenderAs: {EntityCollection, Stable, enum(RenderAsPin, RenderAsText)},273 KeyIconStd: {EntityCollection, Stable, setName},274 KeyIconKind: {EntityCollection, Stable, enum(IconKindGlyph, IconKindPicture)},275 KeyIconOutset: {EntityWorld, Stable, enum(OutsetLight, OutsetDark)},276 KeyGeometrySurface: {EntityWorld, Stable, enum(SurfacePlane, SurfaceSphere)},277 KeyGeometryProjection: {EntityWorld, Stable, enum(ProjectionEquirect)},278 KeyGeometryEquirectPx: {EntityWorld, Stable, numbers(4)},279 KeyGeometryEquirectDeg: {EntityWorld, Stable, numbers(4)},280 KeyGeometryMercatorPx: {EntityWorld, Experimental, numbers(4)},281 KeyGeometryMercatorDeg: {EntityWorld, Experimental, numbers(4)},282 KeyGeometryBody: {EntityWorld, Experimental, slug},283 KeyGeometryRadiusKM: {EntityWorld, Experimental, decimal},284 KeyGeoLat: {EntityFeature, Experimental, decimal},285 KeyGeoLon: {EntityFeature, Experimental, decimal},286 KeyHydroHUC12: {EntityFeature, Experimental, huc12},287 KeyStrokeWidthPx: {EntityCollection, Experimental, positiveDecimal},288 KeyCollectionKey: {EntityCollection, Experimental, slug},289}290291// Keys lists every registered key in a stable order, for the tests and the292// tools that report on adoption.293func Keys() []string {294 out := make([]string, 0, len(registry))295 for key := range registry {296 out = append(out, key)297 }298 sort.Strings(out)299 return out300}301302// EntityOf reports where a key attaches, for tools walking a payload.303func EntityOf(key string) (Entity, bool) {304 definition, known := registry[key]305 return definition.entity, known306}307308// StabilityOf reports how settled a key is.309func StabilityOf(key string) (Stability, bool) {310 definition, known := registry[key]311 return definition.stability, known312}313314// Validate holds one entity's attributes to the registry: every key in the315// atlas namespace must be registered, attached to this entity, and carry a316// value its vocabulary admits. Keys outside the namespace are not this317// registry's business and pass through -- but nothing in the pipeline318// writes any today.319func Validate(entity Entity, attrs map[string]string) error {320 for _, key := range sortedKeys(attrs) {321 if !strings.HasPrefix(key, "atlas.") {322 continue323 }324 definition, known := registry[key]325 if !known {326 return fmt.Errorf("attribute %q is not registered", key)327 }328 if definition.entity != entity {329 return fmt.Errorf("attribute %q attaches to a %s, not a %s", key, definition.entity, entity)330 }331 if err := definition.check(attrs[key]); err != nil {332 return fmt.Errorf("attribute %q: %w", key, err)333 }334 }335 return nil336}337338// RenderAs answers how a point collection draws, from its attributes first339// and the legacy display_type field when no attribute speaks. This is the340// one rule the viewer used to hold as a string compare, spelled once for341// every consumer: text is text, and everything else is a pin. The legacy342// parameter survives only at ingestion, where the generator speaks this key343// for MapGenie captures that predate the conventions; the wire itself344// carries no displayType any more.345func RenderAs(attrs map[string]string, legacyDisplayType string) string {346 if value, declared := attrs[KeyRenderAs]; declared {347 return value348 }349 if legacyDisplayType == "text" {350 return RenderAsText351 }352 return RenderAsPin353}354355// Equirect is a declared flattening of a sphere: the raster window it fills356// in world pixels, and the ground that window pictures in degrees. West may357// exceed east numerically (a map centered on the antimeridian); the span358// still reads west-to-east.359type Equirect struct {360 X, Y, W, H float64361 West, North, East, South float64362}363364// ParseEquirect reads the two mapping attributes into one transform,365// refusing windows with no area.366func ParseEquirect(px, deg string) (Equirect, error) {367 pxParts, err := floats(px, 4)368 if err != nil {369 return Equirect{}, fmt.Errorf("equirect px: %w", err)370 }371 degParts, err := floats(deg, 4)372 if err != nil {373 return Equirect{}, fmt.Errorf("equirect deg: %w", err)374 }375 e := Equirect{376 X: pxParts[0], Y: pxParts[1], W: pxParts[2], H: pxParts[3],377 West: degParts[0], North: degParts[1], East: degParts[2], South: degParts[3],378 }379 if e.W <= 0 || e.H <= 0 {380 return Equirect{}, fmt.Errorf("equirect window %q has no area", px)381 }382 if e.North == e.South || e.West == e.East {383 return Equirect{}, fmt.Errorf("equirect ground %q has no extent", deg)384 }385 return e, nil386}387388// Apply lands true coordinates on the raster: degrees in, world pixels out.389func (e Equirect) Apply(lat, lon float64) (x, y float64) {390 x = e.X + (lon-e.West)/(e.East-e.West)*e.W391 y = e.Y + (e.North-lat)/(e.North-e.South)*e.H392 return x, y393}394395// Invert recovers true coordinates from the raster: world pixels in,396// degrees out. Every pin's packed position runs back through this to stand397// on the sphere, so the mapping declared on the map is the whole story and398// no pin needs to carry its own.399func (e Equirect) Invert(x, y float64) (lat, lon float64) {400 lon = e.West + (x-e.X)/e.W*(e.East-e.West)401 lat = e.North - (y-e.Y)/e.H*(e.North-e.South)402 return lat, lon403}404405func floats(value string, count int) ([]float64, error) {406 parts := strings.Split(value, ",")407 if len(parts) != count {408 return nil, fmt.Errorf("%q wants %d numbers", value, count)409 }410 out := make([]float64, count)411 for at, part := range parts {412 parsed, err := strconv.ParseFloat(strings.TrimSpace(part), 64)413 if err != nil {414 return nil, fmt.Errorf("%q is not a number", part)415 }416 out[at] = parsed417 }418 return out, nil419}420421func sortedKeys(attrs map[string]string) []string {422 out := make([]string, 0, len(attrs))423 for key := range attrs {424 out = append(out, key)425 }426 sort.Strings(out)427 return out428}