clone

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

feat/atlas-next-horizon
atlas / internal / generate / sources / nasatrek / nasatrek.go
nasatrek.go 16.2K
1// Package nasatrek reads a captured planetary map and translates it into the2// Atlas interchange document.3//4// A capture marries two public publications that know nothing of each other: a5// global equirectangular mosaic served by NASA's Trek tile services, and the IAU6// Gazetteer of Planetary Nomenclature's feature list for the same body. This7// package is where a body's named places land on its picture.8//9// # The coordinate design10//11// Trek mosaics are equirectangular and two tiles wide by one tall at their own12// zoom zero, so a Trek level sits one zoom up in the square pyramid the corpus13// cuts: square zoom z holds Trek zoom z-1, 2^z tiles across and 2^(z-1) down,14// the planet filling the top half of the world square. A feature's planetary15// coordinates become a pixel on that image -- x from longitude across the full16// width, y from latitude down the top half -- and the pixel becomes a synthetic17// position through the same inverse Mercator every picture-publishing source18// uses. The projection's distortion cancels exactly, because the raster and the19// features ride one mapping.20//21// Nothing is flattened away by that. The world declares the flattening as22// registered conventions, so any reader can run a packed position backward23// through it and stand on the planet, and every feature carries the coordinates24// the Gazetteer published, verbatim, beside its synthetic ones.25//26// # What is refused27//28// A capture of another source's kind; a capture naming no body, layer or map; a29// capture declaring no pyramid, or a sibling mosaic that declares none; the same30// mosaic captured twice; a Gazetteer entry with no identifier, no name or no31// type, or one sitting off the planet. The reader states its preconditions and32// refuses what does not meet them rather than guessing.33//34// # Determinism35//36// Feature types are sorted, features within a type keep the Gazetteer's own37// identifier order, and every identifier is derived from a stable name. The same38// archived bytes give the same document on any machine.39package nasatrek4041import (42 "encoding/json"43 "errors"44 "fmt"45 "log/slog"46 "math"47 "sort"48 "strconv"4950 "github.com/FelineStateMachine/atlas/format/semconv"51 "github.com/FelineStateMachine/atlas/internal/generate/archive"52 "github.com/FelineStateMachine/atlas/internal/generate/doc"53 "github.com/FelineStateMachine/atlas/internal/logging"54)5556// captureKind is what the archive calls a Trek capture, and source is what a57// well-formed capture says it is.58const (59 captureKind = "trek-map"60 source = "nasa-trek"61)6263// The declared flattening is the corpus's shared whole-sphere window64// (doc.EquirectPx and friends): the mosaic fills the top half of the world65// square and pictures the whole ground, -180..180 west to east and 90..-90 top66// to bottom.6768// Source reads NASA Trek captures.69type Source struct{}7071// New builds the source. It holds no state.72func New() Source { return Source{} }7374// Describe is the source's account of itself.75func (Source) Describe() doc.Provenance {76 return doc.Provenance{77 Name: source,78 Label: "NASA Trek",79 License: "Public domain. NASA and USGS imagery carries no copyright, and " +80 "the IAU Gazetteer of Planetary Nomenclature is published freely.",81 Attribution: "Global mosaics from NASA's Solar System Treks (trek.nasa.gov), " +82 "assembled by NASA, USGS and the mission teams named in each layer. " +83 "Feature names from the IAU Gazetteer of Planetary Nomenclature.",84 // Neither publication numbers things the way a bundle needs: the85 // Gazetteer's identifiers are its own and there is no numbering at all86 // for a mosaic or a feature type, so every identity here is derived from87 // a stable name.88 IDSpace: doc.IDSpaceDerived,89 }90}9192// Translate reads one archived volume.93func (s Source) Translate(a *archive.Archive, v archive.VolumeRef, log *slog.Logger) (doc.Document, error) {94 log = log.With(logging.Source(source))95 worlds, err := a.Worlds(v)96 if err != nil {97 return doc.Document{}, err98 }99 out := doc.Document{100 Doc: doc.Doc,101 Version: doc.Version,102 Source: s.Describe(),103 }104 for _, ref := range worlds {105 world, body, err := s.translateWorld(a, ref, log)106 if err != nil {107 if errors.Is(err, archive.ErrNotReady) {108 log.Debug("world skipped", logging.Path(archive.TrimRoot(a.Root(), ref.Dir())),109 "reason", err.Error())110 continue111 }112 return doc.Document{}, err113 }114 if out.Volume.Slug == "" {115 // A planet is a volume, and its slug is the body's own lowercase116 // name -- free for another source's capture of the same body to117 // answer to, which is the point of naming it that way.118 out.Volume = doc.Volume{Slug: body, Title: doc.Title(body)}119 }120 out.Worlds = append(out.Worlds, world)121 }122 if len(out.Worlds) == 0 {123 return doc.Document{}, fmt.Errorf("%w: volume %s has no readable world", archive.ErrNotReady, v.Title)124 }125 log.Info("volume translated", logging.Volume(out.Volume.Slug), "worlds", len(out.Worlds))126 return out, nil127}128129func (s Source) translateWorld(a *archive.Archive, ref archive.WorldRef, log *slog.Logger) (doc.World, string, error) {130 archived, err := a.Newest(ref)131 if err != nil {132 return doc.World{}, "", err133 }134 if archived.Kind != captureKind {135 return doc.World{}, "", fmt.Errorf(136 "capture %s is of kind %q; the NASA Trek reader answers only for %q",137 archived.ContentHash, archived.Kind, captureKind)138 }139 body, err := a.Body(ref, archived)140 if err != nil {141 return doc.World{}, "", err142 }143 var raw capture144 if err := json.Unmarshal(body, &raw); err != nil {145 return doc.World{}, "", fmt.Errorf("decode capture %s: %w", archived.ContentHash, err)146 }147 if err := check(&raw); err != nil {148 return doc.World{}, "", fmt.Errorf("capture %s: %w", archived.ContentHash, err)149 }150 raw.normalize()151152 ids := doc.NewIDSpace()153 scope := raw.Body + "/" + raw.MapSlug154 worldID, err := ids.Claim("trek:map:" + scope)155 if err != nil {156 return doc.World{}, "", err157 }158159 world := doc.World{160 ID: worldID,161 Slug: raw.MapSlug,162 Title: named(raw.MapTitle, raw.MapSlug),163 // A reader opens on the middle of the planet's picture.164 Center: doc.EquirectCenter(),165 Capture: doc.Capture{166 Kind: archived.Kind,167 ID: archived.SourceID,168 Locator: archived.SourceURL,169 ContentHash: archived.ContentHash,170 CapturedAt: archived.CapturedAt,171 },172 // The world says what it pictures: a sphere, flattened by the173 // equirectangular projection into the top half of the world square. The174 // mapping is the whole story, and it is exactly the transform175 // worldPixel projects features through, so the two cannot disagree176 // without a test noticing.177 Attrs: map[string]string{178 semconv.KeyGeometrySurface: semconv.SurfaceSphere,179 semconv.KeyGeometryProjection: semconv.ProjectionEquirect,180 semconv.KeyGeometryEquirectPx: doc.EquirectPx,181 semconv.KeyGeometryEquirectDeg: doc.EquirectDeg,182 semconv.KeyGeometryBody: raw.Body,183 },184 }185 world.Lenses = append(world.Lenses, doc.Lens{186 Name: named(raw.Map.LayerTitle, "default"),187 TileSet: TileSetPath(raw.Body, raw.Layer),188 Frame: frameOf(raw.Map.MaxZoom, raw.Map.Extension),189 })190 for _, sibling := range raw.Variants {191 world.Lenses = append(world.Lenses, doc.Lens{192 Name: named(sibling.Title, "default"),193 TileSet: TileSetPath(raw.Body, sibling.Layer),194 Frame: frameOf(sibling.MaxZoom, sibling.Extension),195 })196 }197198 collections, err := collectionsOf(&raw, ids, scope)199 if err != nil {200 return doc.World{}, "", err201 }202 world.Collections = collections203 log.Debug("world translated", logging.World(raw.MapSlug),204 "collections", len(collections), "capture", archived.ContentHash)205 return world, raw.Body, nil206}207208// check states what a capture has to be before anything is read out of it.209func check(raw *capture) error {210 if raw.Source != source {211 return fmt.Errorf("capture says its source is %q, not %q", raw.Source, source)212 }213 if raw.Body == "" || raw.Layer == "" || raw.MapSlug == "" {214 return fmt.Errorf("capture names no map")215 }216 if raw.Map.MaxZoom < 1 {217 return fmt.Errorf("capture declares no pyramid")218 }219 if len(raw.Features) == 0 {220 return fmt.Errorf("capture carries no features")221 }222 taken := map[string]bool{raw.Layer: true}223 for _, sibling := range raw.Variants {224 if sibling.Layer == "" || sibling.MaxZoom < 1 {225 return fmt.Errorf("sibling mosaic %q declares no pyramid", sibling.Layer)226 }227 if taken[sibling.Layer] {228 return fmt.Errorf("mosaic %q is captured twice", sibling.Layer)229 }230 taken[sibling.Layer] = true231 }232 return nil233}234235// TileSetPath is the path a body's mosaic is captured under. It is exported236// because the crawler writes tiles at it and the reader names it, and the two237// have to agree.238func TileSetPath(body, layer string) string { return body + "/EQ/" + layer }239240// LevelExtent reports the last tile column and row of one square level: the full241// width of the world square, and the top half of its height. The crawler asks242// for exactly these tiles and the deriver expects exactly these bounds, because243// it is the same call -- the shared whole-sphere one every equirectangular244// source measures with.245func LevelExtent(zoom int) (maxX, maxY int) { return doc.EquirectLevelExtent(zoom) }246247// collectionsOf arranges the Gazetteer's flat feature list the way a legend248// reads: one collection per feature type, all under one heading. The type's249// descriptor -- "Crater, craters" names the singular and the plural -- keeps its250// singular half as the collection title and lends its slug as the artwork key,251// so artwork dropped into an archive later attaches without a policy change.252func collectionsOf(raw *capture, ids *doc.IDSpace, scope string) ([]doc.Collection, error) {253 byType := make(map[string][]feature)254 var order []string255 for _, entry := range raw.Features {256 if err := checkFeature(entry); err != nil {257 return nil, err258 }259 if _, seen := byType[entry.Type]; !seen {260 order = append(order, entry.Type)261 }262 byType[entry.Type] = append(byType[entry.Type], entry)263 }264 sort.Strings(order)265266 out := make([]doc.Collection, 0, len(order))267 for _, featureType := range order {268 name := typeName(featureType)269 id, err := ids.Claim("trek:type:" + scope + ":" + name)270 if err != nil {271 return nil, err272 }273 attrs := map[string]string{semconv.KeyRenderAs: semconv.RenderAsPin}274 // The standard glyph is named, not resolved: resolving it against the275 // icon library is an enricher's work, and a source that resolved it276 // would be deciding what artwork a bundle carries.277 if standard := standardIcons[byType[featureType][0].Code]; standard != "" {278 attrs[semconv.KeyIconStd] = standard279 }280 collection := doc.Collection{281 ID: id,282 Title: name,283 Group: "Nomenclature",284 Kind: doc.KindPoint,285 Icon: doc.Slugify(name),286 Visible: true,287 Attrs: attrs,288 Features: make([]doc.Feature, 0, len(byType[featureType])),289 }290 for _, entry := range byType[featureType] {291 featureID, err := ids.Claim("trek:feature:" + strconv.FormatInt(entry.ID, 10))292 if err != nil {293 return nil, err294 }295 x, y := worldPixel(entry.Longitude, entry.Latitude)296 at := doc.SyntheticPosition(x, y)297 collection.Features = append(collection.Features, doc.Feature{298 ID: featureID,299 Title: entry.Name,300 Description: describe(entry),301 At: &at,302 // The coordinates as the Gazetteer published them, verbatim:303 // provenance beside the synthetic position, so no reader has to304 // parse a card to learn where a place truly is.305 Attrs: map[string]string{306 semconv.KeyGeoLat: strconv.FormatFloat(entry.Latitude, 'f', -1, 64),307 semconv.KeyGeoLon: strconv.FormatFloat(entry.Longitude, 'f', -1, 64),308 },309 })310 }311 out = append(out, collection)312 }313 return out, nil314}315316func checkFeature(entry feature) error {317 switch {318 case entry.ID <= 0:319 return fmt.Errorf("feature %q has no identifier", entry.Name)320 case entry.Name == "":321 return fmt.Errorf("feature %d has no name", entry.ID)322 case entry.Type == "":323 return fmt.Errorf("feature %q has no type", entry.Name)324 case entry.Latitude < -90 || entry.Latitude > 90:325 return fmt.Errorf("feature %q sits at latitude %v", entry.Name, entry.Latitude)326 case entry.Longitude < 0 || entry.Longitude > 360:327 return fmt.Errorf("feature %q sits at longitude %v", entry.Name, entry.Longitude)328 }329 return nil330}331332// worldPixel lands a feature's planetary coordinates on the picture, through333// the shared whole-sphere window. The Gazetteer speaks east-positive 0..360,334// which the shared mapping wraps into the mosaic's half-open window first.335func worldPixel(longitude, latitude float64) (x, y float64) {336 return doc.EquirectWorldPixel(longitude, latitude)337}338339// frameOf declares a mosaic's pyramid for the deriver: the shared whole-sphere340// frame, every level down to zero.341func frameOf(maxZoom int, format string) *doc.Frame {342 return doc.EquirectFrame(maxZoom, format)343}344345func named(given, slug string) string {346 if given != "" {347 return given348 }349 return doc.Title(slug)350}351352// typeName keeps the singular half of the Gazetteer's "Crater, craters"353// descriptor; a descriptor without a plural half is already the name.354func typeName(descriptor string) string {355 for at, r := range descriptor {356 if r == ',' {357 return descriptor[:at]358 }359 }360 return descriptor361}362363// describe writes a feature's card: its real place on the planet, its size where364// the Gazetteer gives one, and the origin of its name.365func describe(entry feature) string {366 text := place(entry.Latitude, entry.Longitude)367 if entry.DiameterKM > 0 {368 text += fmt.Sprintf(" · %s km across", trimmed(entry.DiameterKM))369 }370 if entry.Origin != "" {371 text += " — " + entry.Origin372 }373 return text374}375376func place(latitude, longitude float64) string {377 ns := "N"378 if latitude < 0 {379 ns, latitude = "S", -latitude380 }381 return fmt.Sprintf("%s°%s %s°E", trimmed(latitude), ns, trimmed(longitude))382}383384// trimmed spells a value to two decimals and no further, dropping the trailing385// zeroes a card does not need.386func trimmed(value float64) string {387 return strconv.FormatFloat(math.Round(value*100)/100, 'f', -1, 64)388}389390// standardIcons names a library glyph for each Gazetteer feature-type code, in391// the set/name vocabulary of atlas.icon.std. The codes are the IAU's own and392// hold across bodies, so the Moon's craters will wear the same rim the day a393// capture arrives. The reading leans semantic -- a mons is a mountain, a patera394// a volcano, a palus literally a marsh -- and falls back to shape language where395// no glyph says the thing: rimmed circles for depressions, squares for plains,396// triangles for relief. A code missing here is not an error; its collection goes397// without, and re-curating is one table edit and a re-translation of captures398// already on disk.399var standardIcons = map[string]string{400 "AA": "maki/circle-stroked", // crater: a rim401 "CA": "maki/circle-stroked", // catena: a chain of them402 "CB": "maki/circle-stroked", // cavus: a hollow403 "AL": "maki/circle", // albedo feature: a patch of tone404 "MA": "maki/circle", // macula: a dark spot405 "MO": "maki/mountain", // mons406 "TH": "maki/mountain", // tholus: a small domical mountain407 "CO": "maki/triangle-stroked", // collis: small hills408 "DO": "maki/triangle-stroked", // dorsum: a ridge409 "RU": "maki/triangle", // rupes: a scarp410 "SC": "maki/triangle", // scopulus: a lobate scarp411 "PA": "maki/volcano", // patera: an irregular volcanic crater412 "CH": "maki/caution", // chaos: broken terrain413 "LA": "maki/caution", // labes: a landslide414 "CM": "maki/wetland", // chasma: parallel-walled canyon, read as lines415 "FO": "maki/wetland", // fossa: a long narrow trench416 "LB": "maki/wetland", // labyrinthus: intersecting valleys417 "SU": "maki/wetland", // sulcus: subparallel furrows418 "PE": "maki/wetland", // palus: literally a marsh419 "VA": "maki/water", // vallis: a channel something carved420 "SE": "maki/water", // serpens: a sinuous feature421 "FL": "maki/waterfall", // fluctus: flow terrain422 "PL": "maki/square-stroked", // planitia: a low plain423 "VS": "maki/square-stroked", // vastitas: a vast one424 "PM": "maki/square", // planum: a plateau425 "MN": "maki/square", // mensa: a mesa426 "TA": "maki/landuse", // terra: extensive land427 "LN": "maki/landuse", // lingula: a tongue of plateau428 "UN": "maki/beach", // unda: dunes429}