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 / trekmap / trekmap.go
trekmap.go 18.0K
1// Package trekmap reads a captured planetary map and speaks MapGenie for it.2//3// The capture marries two public NASA/USGS publications: a global equirectangular4// mosaic served by NASA's Trek tile services, and the IAU Gazetteer of Planetary5// Nomenclature's feature list for the same body. Neither knows the other exists;6// this package is where a body's named places land on its picture. Like ignmap7// and pbmap, translation happens when a capture is read, not when it is taken --8// the archive keeps what NASA and the IAU published, and every keep-or-leave9// decision lives here, where changing it re-applies to captures already on disk.10//11// The coordinate design is the whole trick. Trek mosaics are equirectangular,12// two tiles wide and one tall at their zoom zero, so a Trek level sits in the13// pipeline's square pyramid one zoom up: pipeline zoom z holds Trek zoom z-1,14// 2^z tiles across and 2^(z-1) down, the planet filling the top half of the15// world square the way a wikimap narrower than tall fills part of its own.16// A feature's planetary coordinates become a pixel on that image -- x from17// longitude across the full width, y from latitude down the top half -- and18// the pixel becomes synthetic latitude and longitude through the same inverse19// Mercator every other source uses. Mercator's distortion cancels exactly,20// because the raster and the pins ride the same mapping; the real planetary21// coordinates stay in the capture, and in the pin's own text, so nothing is22// flattened away.23package trekmap2425import (26 "encoding/json"27 "fmt"28 "math"29 "sort"30 "strconv"3132 "github.com/FelineStateMachine/atlas/internal/mgdoc"33 "github.com/FelineStateMachine/atlas/internal/semconv"34)3536// Kind names Trek captures in a map's snapshot index.37const Kind = "trek-map"3839// Source is the value a well-formed capture carries in its source field.40const Source = "nasa-trek"4142// Capture is the canonical document the crawler stores: which body and mosaic43// the raster is, every sibling mosaic captured beside it, and every named44// feature the Gazetteer publishes for the body. Fields marshal in declaration45// order and Normalize sorts the lists, so unchanged data always hashes to the46// capture already archived. Variants is newer than the field's first captures;47// a capture without one is simply a body pictured a single way.48type Capture struct {49 Source string `json:"source"`50 Body string `json:"body"`51 Layer string `json:"layer"`52 MapSlug string `json:"mapSlug"`53 MapTitle string `json:"mapTitle"`54 Map MapConfig `json:"map"`55 Variants []Variant `json:"variants,omitempty"`56 Features []Feature `json:"features"`57}5859// MapConfig is the pyramid as captured. MaxZoom counts in the pipeline's60// square world -- the deepest level actually taken, one above the Trek level61// it came from. LayerTitle names the mosaic the way a person knows it, which62// is what the viewer's layer picker shows.63type MapConfig struct {64 MaxZoom int `json:"maxZoom"`65 Extension string `json:"extension"`66 LayerTitle string `json:"layerTitle"`67}6869// Variant is a sibling mosaic of the same ground: another way of seeing the70// body -- elevation beside photograph -- captured into the same window, so71// the viewer offers it the way it offers a game's satellite layer beside its72// atlas. Its pyramid is its own; siblings need not agree on depth.73type Variant struct {74 Layer string `json:"layer"`75 Title string `json:"title"`76 MaxZoom int `json:"maxZoom"`77 Extension string `json:"extension"`78}7980// Feature is one Gazetteer entry, as the feature list publishes it: the IAU's81// own feature identifier, planetocentric latitude, east-positive longitude in82// the Gazetteer's 0..360 spelling, diameter in kilometers, and the origin text83// explaining the name -- the one piece of prose the data carries.84type Feature struct {85 ID int64 `json:"id"`86 Name string `json:"name"`87 Type string `json:"type"`88 Code string `json:"code"`89 Latitude float64 `json:"latitude"`90 Longitude float64 `json:"longitude"`91 DiameterKM float64 `json:"diameterKm"`92 Origin string `json:"origin,omitempty"`93}9495// Normalize puts the capture into its canonical order. The crawler calls this96// before marshaling, so the Gazetteer listing its features differently does97// not masquerade as a new version.98func (c *Capture) Normalize() {99 sort.Slice(c.Variants, func(a, b int) bool { return c.Variants[a].Layer < c.Variants[b].Layer })100 sort.Slice(c.Features, func(a, b int) bool { return c.Features[a].ID < c.Features[b].ID })101}102103// TileSetPath is the layer path tile records group under, matching what104// tools/tiles recovers from a canonical tile URL: the segments between105// trek.nasa.gov's /tiles/ marker and the zoom.106func TileSetPath(body, layer string) string {107 return body + "/EQ/" + layer108}109110// LevelExtent reports the last tile column and row of one pipeline level: the111// full width of the world square, and the top half of its height. The crawler112// asks for exactly these tiles, and Translate declares exactly these bounds,113// because they are the same call.114func LevelExtent(zoom int) (maxX, maxY int) {115 if zoom == 0 {116 return 0, 0117 }118 return 1<<zoom - 1, 1<<(zoom-1) - 1119}120121// MaybeTranslate hands other sources' snapshots through untouched and122// translates Trek captures.123func MaybeTranslate(kind string, doc []byte) ([]byte, error) {124 if kind != Kind {125 return doc, nil126 }127 return Translate(doc)128}129130// Translate turns a capture into the MapGenie-shaped document the pipeline131// reads, deterministically for a given capture.132func Translate(doc []byte) ([]byte, error) {133 var capture Capture134 if err := json.Unmarshal(doc, &capture); err != nil {135 return nil, fmt.Errorf("decode trek capture: %w", err)136 }137 if capture.Source != Source {138 return nil, fmt.Errorf("capture source is %q, not %q", capture.Source, Source)139 }140 if capture.Body == "" || capture.Layer == "" || capture.MapSlug == "" {141 return nil, fmt.Errorf("capture names no map")142 }143 if capture.Map.MaxZoom < 1 {144 return nil, fmt.Errorf("capture declares no pyramid")145 }146 if len(capture.Features) == 0 {147 return nil, fmt.Errorf("capture carries no features")148 }149 taken := map[string]bool{capture.Layer: true}150 for _, variant := range capture.Variants {151 if variant.Layer == "" || variant.MaxZoom < 1 {152 return nil, fmt.Errorf("variant %q declares no pyramid", variant.Layer)153 }154 if taken[variant.Layer] {155 return nil, fmt.Errorf("layer %q is captured twice", variant.Layer)156 }157 taken[variant.Layer] = true158 }159 capture.Normalize()160161 ids := mgdoc.NewIDSpace()162 scope := capture.Body + "/" + capture.MapSlug163 mapID, err := ids.Claim("trek:map:" + scope)164 if err != nil {165 return nil, err166 }167 gameID, err := ids.Claim("trek:game:" + capture.Body)168 if err != nil {169 return nil, err170 }171172 groups, err := buildGroups(&capture, ids, scope)173 if err != nil {174 return nil, err175 }176177 sets := []mgdoc.TileSet{tileSet(capture.Body, capture.Layer,178 title(capture.Map.LayerTitle, "default"), capture.Map.Extension, capture.Map.MaxZoom)}179 for _, variant := range capture.Variants {180 sets = append(sets, tileSet(capture.Body, variant.Layer,181 title(variant.Title, "default"), variant.Extension, variant.MaxZoom))182 }183184 out := mgdoc.Map{185 ID: mapID,186 Title: title(capture.MapTitle, capture.MapSlug),187 Slug: capture.MapSlug,188 // The map opens on the middle of the planet's picture: halfway across189 // the world, a quarter down it, which is where a 2:1 image's centre190 // sits in the square.191 InitialLatitude: mgdoc.SyntheticLatitude(mgdoc.WorldSize / 4),192 InitialLongitude: mgdoc.SyntheticLongitude(mgdoc.WorldSize / 2),193 Config: mgdoc.Config{TileSets: sets},194 // The map says what it pictures: a sphere, flattened by the equi-195 // rectangular projection into the top half of the world square. The196 // mapping is the whole story -- any reader can run a pin's packed197 // position backward through it and stand on the planet -- and it is198 // exactly the transform worldPixel projects features through, so the199 // two can never disagree without a test noticing.200 Attrs: map[string]string{201 semconv.KeyGeometrySurface: semconv.SurfaceSphere,202 semconv.KeyGeometryProjection: semconv.ProjectionEquirect,203 semconv.KeyGeometryEquirectPx: equirectPx,204 semconv.KeyGeometryEquirectDeg: equirectDeg,205 semconv.KeyGeometryBody: capture.Body,206 },207 Game: mgdoc.Game{208 ID: gameID,209 // A planet is a volume to the bundle, spelled in the archive's210 // upstream game shape here, which is the point being211 // proven: the slug is the body's own lowercase name, free for212 // another source's capture of the same body to answer to.213 Title: mgdoc.SpellOut(capture.Body),214 Slug: capture.Body,215 },216 Groups: groups,217 Regions: []mgdoc.Region{},218 }219 return json.Marshal(out)220}221222func title(given, slug string) string {223 if given != "" {224 return given225 }226 return mgdoc.SpellOut(slug)227}228229// tileSet declares one layer's pyramid in the shape tools/tiles expects.230// Bounds are declared for every level down to zero: a level without them231// would be measured against the MapGenie window this map does not sit in,232// and the half-height windows are what tell the frame derivation where the233// planet actually is. Every layer of a body shares that window whatever its234// depth, which is what lets siblings ride one map as its variants.235func tileSet(body, layer, name, ext string, maxZoom int) mgdoc.TileSet {236 set := mgdoc.TileSet{237 Name: name,238 Path: TileSetPath(body, layer),239 MinZoom: 0,240 MaxZoom: maxZoom,241 Extension: extension(ext),242 Bounds: make(map[string]mgdoc.Bound, maxZoom+1),243 }244 for zoom := 0; zoom <= maxZoom; zoom++ {245 maxX, maxY := LevelExtent(zoom)246 set.Bounds[strconv.Itoa(zoom)] = mgdoc.Bound{247 X: mgdoc.Range{Min: 0, Max: maxX},248 Y: mgdoc.Range{Min: 0, Max: maxY},249 }250 }251 return set252}253254func extension(given string) string {255 if given != "" {256 return given257 }258 return "jpg"259}260261// The declared flattening, spelled once for the map attributes and for262// worldPixel below: the mosaic fills the top half of the world square and263// pictures the whole ground, -180..180 west to east, 90..-90 top to bottom.264const (265 equirectPx = "0,0,8192,4096"266 equirectDeg = "-180,90,180,-90"267)268269// worldPixel lands a feature's planetary coordinates on the picture. The270// mosaic spans longitude -180..180 west to east and latitude 90..-90 top to271// bottom; the Gazetteer speaks east-positive 0..360, so longitudes wrap into272// the mosaic's half-open window first. X crosses the full world square, y273// only its top half, because that is where a 2:1 image sits in it.274func worldPixel(longitude, latitude float64) (x, y float64) {275 wrapped := math.Mod(longitude+180, 360) - 180276 x = (wrapped + 180) / 360 * mgdoc.WorldSize277 y = (90 - latitude) / 180 * (mgdoc.WorldSize / 2)278 return x, y279}280281// standardIcons names a library glyph for each Gazetteer feature-type code,282// in the set/name vocabulary of atlas.icon.std. The codes are the IAU's own283// and hold across bodies, so the Moon's craters will wear the same rim the284// day a capture arrives. The reading leans semantic -- a mons is a mountain,285// a patera a volcano, a palus literally a marsh -- and falls back to shape286// language where no glyph says the thing: rimmed circles for depressions,287// squares for plains, triangles for relief. A code missing here is not an288// error; its category keeps the viewer's initials until someone curates it,289// and re-curating is one table edit and a re-translation of captures already290// on disk.291var standardIcons = map[string]string{292 "AA": "maki/circle-stroked", // crater: a rim293 "CA": "maki/circle-stroked", // catena: a chain of them294 "CB": "maki/circle-stroked", // cavus: a hollow295 "AL": "maki/circle", // albedo feature: a patch of tone296 "MA": "maki/circle", // macula: a dark spot297 "MO": "maki/mountain", // mons298 "TH": "maki/mountain", // tholus: a small domical mountain299 "CO": "maki/triangle-stroked", // collis: small hills300 "DO": "maki/triangle-stroked", // dorsum: a ridge301 "RU": "maki/triangle", // rupes: a scarp302 "SC": "maki/triangle", // scopulus: a lobate scarp303 "PA": "maki/volcano", // patera: an irregular volcanic crater304 "CH": "maki/caution", // chaos: broken terrain305 "LA": "maki/caution", // labes: a landslide306 "CM": "maki/wetland", // chasma: parallel-walled canyon, read as lines307 "FO": "maki/wetland", // fossa: a long narrow trench308 "LB": "maki/wetland", // labyrinthus: intersecting valleys309 "SU": "maki/wetland", // sulcus: subparallel furrows310 "PE": "maki/wetland", // palus: literally a marsh311 "VA": "maki/water", // vallis: a channel something carved312 "SE": "maki/water", // serpens: a sinuous feature313 "FL": "maki/waterfall", // fluctus: flow terrain314 "PL": "maki/square-stroked", // planitia: a low plain315 "VS": "maki/square-stroked", // vastitas: a vast one316 "PM": "maki/square", // planum: a plateau317 "MN": "maki/square", // mensa: a mesa318 "TA": "maki/landuse", // terra: extensive land319 "LN": "maki/landuse", // lingula: a tongue of plateau320 "UN": "maki/beach", // unda: dunes321}322323// buildGroups arranges the Gazetteer's flat feature list the way the pipeline324// expects: one category per feature type, all under one group. The type's325// descriptor -- "Crater, craters" names the singular and the plural -- keeps326// its singular half as the category title and lends its slug as the icon key,327// so artwork dropped into the archive later attaches without a policy change;328// until then the viewer's fallback glyph stands in.329func buildGroups(capture *Capture, ids *mgdoc.IDSpace, scope string) ([]mgdoc.Group, error) {330 byType := make(map[string][]Feature)331 var order []string332 for _, feature := range capture.Features {333 if err := check(feature); err != nil {334 return nil, err335 }336 if _, seen := byType[feature.Type]; !seen {337 order = append(order, feature.Type)338 }339 byType[feature.Type] = append(byType[feature.Type], feature)340 }341 sort.Strings(order)342343 categories := make([]mgdoc.Category, 0, len(order))344 for _, featureType := range order {345 name := typeName(featureType)346 categoryID, err := ids.Claim("trek:type:" + scope + ":" + name)347 if err != nil {348 return nil, err349 }350 attrs := map[string]string{semconv.KeyRenderAs: semconv.RenderAsPin}351 if standard := standardIcons[byType[featureType][0].Code]; standard != "" {352 attrs[semconv.KeyIconStd] = standard353 }354 category := mgdoc.Category{355 ID: categoryID,356 Title: name,357 Icon: iconKey(name),358 DisplayType: "markers",359 Visible: true,360 Locations: make([]mgdoc.Location, 0, len(byType[featureType])),361 Attrs: attrs,362 }363 for _, feature := range byType[featureType] {364 locationID, err := ids.Claim("trek:feature:" + strconv.FormatInt(feature.ID, 10))365 if err != nil {366 return nil, err367 }368 x, y := worldPixel(feature.Longitude, feature.Latitude)369 category.Locations = append(category.Locations, mgdoc.Location{370 ID: locationID,371 Title: feature.Name,372 Description: describe(feature),373 Latitude: mgdoc.SyntheticLatitude(y),374 Longitude: mgdoc.SyntheticLongitude(x),375 // The coordinates as the Gazetteer published them, verbatim:376 // provenance beside the synthetic position, so no reader ever377 // has to parse a pin's card to learn where it truly is.378 Attrs: map[string]string{379 semconv.KeyGeoLat: strconv.FormatFloat(feature.Latitude, 'f', -1, 64),380 semconv.KeyGeoLon: strconv.FormatFloat(feature.Longitude, 'f', -1, 64),381 },382 })383 }384 categories = append(categories, category)385 }386387 groupID, err := ids.Claim("trek:group:" + scope)388 if err != nil {389 return nil, err390 }391 return []mgdoc.Group{{ID: groupID, Title: "Nomenclature", Categories: categories}}, nil392}393394func check(feature Feature) error {395 switch {396 case feature.ID <= 0:397 return fmt.Errorf("feature %q has no identifier", feature.Name)398 case feature.Name == "":399 return fmt.Errorf("feature %d has no name", feature.ID)400 case feature.Type == "":401 return fmt.Errorf("feature %q has no type", feature.Name)402 case feature.Latitude < -90 || feature.Latitude > 90:403 return fmt.Errorf("feature %q sits at latitude %v", feature.Name, feature.Latitude)404 case feature.Longitude < 0 || feature.Longitude > 360:405 return fmt.Errorf("feature %q sits at longitude %v", feature.Name, feature.Longitude)406 }407 return nil408}409410// iconKey spells a category title the way icon files are named, so artwork411// for "Albedo Feature" lands at albedo-feature like every other source's.412func iconKey(name string) string {413 out := make([]rune, 0, len(name))414 for _, r := range name {415 switch {416 case r >= 'a' && r <= 'z' || r >= '0' && r <= '9':417 out = append(out, r)418 case r >= 'A' && r <= 'Z':419 out = append(out, r+'a'-'A')420 default:421 if len(out) > 0 && out[len(out)-1] != '-' {422 out = append(out, '-')423 }424 }425 }426 for len(out) > 0 && out[len(out)-1] == '-' {427 out = out[:len(out)-1]428 }429 return string(out)430}431432// typeName keeps the singular half of the Gazetteer's "Crater, craters"433// descriptor; a descriptor without the plural half is already the name.434func typeName(descriptor string) string {435 for at, r := range descriptor {436 if r == ',' {437 return descriptor[:at]438 }439 }440 return descriptor441}442443// describe writes the pin's card: the feature's real place on the planet, its444// size when the Gazetteer gives one, and the origin of its name. The card is445// where the true coordinates survive for a reader, since the pin itself446// stores only synthetic ones.447func describe(feature Feature) string {448 text := place(feature.Latitude, feature.Longitude)449 if feature.DiameterKM > 0 {450 text += fmt.Sprintf(" · %s km across", trimmed(feature.DiameterKM))451 }452 if feature.Origin != "" {453 text += " — " + feature.Origin454 }455 return text456}457458func place(latitude, longitude float64) string {459 ns := "N"460 if latitude < 0 {461 ns, latitude = "S", -latitude462 }463 return fmt.Sprintf("%s°%s %s°E", trimmed(latitude), ns, trimmed(longitude))464}465466// trimmed spells a value to two decimals and no further, dropping the467// trailing zeroes a card does not need.468func trimmed(value float64) string {469 return strconv.FormatFloat(math.Round(value*100)/100, 'f', -1, 64)470}