clone

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

main
atlas / internal / generate / sources / nasabluemarble / nasabluemarble.go
nasabluemarble.go 8.4K
1// Package nasabluemarble reads a captured NASA Blue Marble base map and2// translates it into the Atlas interchange document.3//4// The capture is one pinned publication: Blue Marble Next Generation, NASA5// Earth Observatory's global, cloud-free, true-color composite of the whole6// planet. Where the NASA Trek reader marries a mosaic to a gazetteer, this7// reader marries a picture to the lightest cut of ground truth: one world, one8// lens, the primary capitals as a point collection per continent, and the9// country borders as one quiet area collection -- Natural Earth's 1:110m10// public-domain vectors, distilled into the capture beside the raster. It11// exists so that Earth can travel as an ordinary volume: the included build12// every library starts with, a working demo of pins and ground, and the base13// later readings enrich.14//15// # The coordinate design16//17// The image is the shared whole-sphere equirectangular window (doc.EquirectPx):18// a 2:1 global image filling the top half of the world square, -180..180 west19// to east and 90..-90 top to bottom. The world declares the flattening as20// registered conventions, so a reader can run any position backward through it21// and stand on the planet, and the cell systems recognise the ground through22// the same declarations every spherical world makes.23//24// # What is refused25//26// A capture of another source's kind or naming another source; a capture of a27// body that is not the Earth; one declaring no pyramid, no digest, or an image28// with no size. The reader states its preconditions and refuses what does not29// meet them rather than guessing.30package nasabluemarble3132import (33 "encoding/json"34 "errors"35 "fmt"36 "log/slog"3738 "github.com/FelineStateMachine/atlas/format/semconv"39 "github.com/FelineStateMachine/atlas/internal/generate/archive"40 "github.com/FelineStateMachine/atlas/internal/generate/doc"41 "github.com/FelineStateMachine/atlas/internal/logging"42)4344// CaptureKind is what the archive calls a Blue Marble capture, and SourceName45// is what a well-formed capture says it is. Both are exported because the46// crawler writes them and the reader answers only for them, and the two have to47// agree.48const (49 CaptureKind = "blue-marble-map"50 SourceName = "nasa-blue-marble"51)5253// Body is the one body this source pictures. The volume and its world both54// answer to it, which is the point: another source's capture of the same planet55// registers the same slug and the two builds answer for one library entry.56const Body = "earth"5758// TileSet is the path the base map's tiles are captured under. It is exported59// because the crawler writes tiles at it and the reader names it, and the two60// have to agree.61const TileSet = Body + "/blue-marble"6263// radiusKM is the Earth's mean radius as the IAU nominal value spells it,64// declared on the world so a reader that measures ground has the number the65// picture was flattened from.66const radiusKM = "6371.0088"6768// Source reads NASA Blue Marble 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: SourceName,78 Label: "NASA Earth Observatory",79 License: "Public domain. NASA imagery carries no copyright; NASA's media guidelines " +80 "ask that NASA Earth Observatory be credited and that no NASA endorsement be implied. " +81 "Natural Earth data is in the public domain.",82 Attribution: "Blue Marble: Next Generation, a global true-color base map by " +83 "NASA Earth Observatory, from NASA Goddard Space Flight Center imagery. " +84 "Country borders and capitals from Natural Earth, drawn de facto as that " +85 "project draws them.",86 // Nothing in the publication numbers a base map, so every identity here87 // is derived from 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(SourceName))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 Volume: doc.Volume{Slug: Body, Title: doc.Title(Body)},103 Source: s.Describe(),104 }105 for _, ref := range worlds {106 world, err := s.translateWorld(a, ref, log)107 if err != nil {108 if errors.Is(err, archive.ErrNotReady) {109 log.Debug("world skipped", logging.Path(archive.TrimRoot(a.Root(), ref.Dir())),110 "reason", err.Error())111 continue112 }113 return doc.Document{}, err114 }115 out.Worlds = append(out.Worlds, world)116 }117 if len(out.Worlds) == 0 {118 return doc.Document{}, fmt.Errorf("%w: volume %s has no readable world", archive.ErrNotReady, v.Title)119 }120 log.Info("volume translated", logging.Volume(out.Volume.Slug), "worlds", len(out.Worlds))121 return out, nil122}123124func (s Source) translateWorld(a *archive.Archive, ref archive.WorldRef, log *slog.Logger) (doc.World, error) {125 archived, err := a.Newest(ref)126 if err != nil {127 return doc.World{}, err128 }129 if archived.Kind != CaptureKind {130 return doc.World{}, fmt.Errorf(131 "capture %s is of kind %q; the Blue Marble reader answers only for %q",132 archived.ContentHash, archived.Kind, CaptureKind)133 }134 body, err := a.Body(ref, archived)135 if err != nil {136 return doc.World{}, err137 }138 var raw capture139 if err := json.Unmarshal(body, &raw); err != nil {140 return doc.World{}, fmt.Errorf("decode capture %s: %w", archived.ContentHash, err)141 }142 if err := check(&raw); err != nil {143 return doc.World{}, fmt.Errorf("capture %s: %w", archived.ContentHash, err)144 }145146 ids := doc.NewIDSpace()147 worldID, err := ids.Claim("bluemarble:map:" + raw.Body)148 if err != nil {149 return doc.World{}, err150 }151152 world := doc.World{153 ID: worldID,154 Slug: raw.Body,155 Title: doc.Title(raw.Body),156 // A reader opens on the middle of the planet's picture.157 Center: doc.EquirectCenter(),158 Capture: doc.Capture{159 Kind: archived.Kind,160 ID: archived.SourceID,161 Locator: archived.SourceURL,162 ContentHash: archived.ContentHash,163 CapturedAt: archived.CapturedAt,164 },165 // The world says what it pictures: the Earth, flattened by the166 // equirectangular projection into the top half of the world square.167 // The declarations are the shared whole-sphere ones, plus the body and168 // its radius, so the cell systems and any measuring reader stand on169 // the same ground the raster pictures.170 Attrs: map[string]string{171 semconv.KeyGeometrySurface: semconv.SurfaceSphere,172 semconv.KeyGeometryProjection: semconv.ProjectionEquirect,173 semconv.KeyGeometryEquirectPx: doc.EquirectPx,174 semconv.KeyGeometryEquirectDeg: doc.EquirectDeg,175 semconv.KeyGeometryBody: raw.Body,176 semconv.KeyGeometryRadiusKM: radiusKM,177 },178 Lenses: []doc.Lens{{179 Name: named(raw.Map.LayerTitle, "blue-marble"),180 TileSet: TileSet,181 Frame: doc.EquirectFrame(raw.Map.MaxZoom, raw.Map.Extension),182 }},183 }184 collections, err := collectionsOf(&raw, ids)185 if err != nil {186 return doc.World{}, err187 }188 world.Collections = collections189 log.Debug("world translated", logging.World(world.Slug), "capture", archived.ContentHash)190 return world, nil191}192193// check states what a capture has to be before anything is read out of it.194func check(raw *capture) error {195 switch {196 case raw.Source != SourceName:197 return fmt.Errorf("capture says its source is %q, not %q", raw.Source, SourceName)198 case raw.Body != Body:199 return fmt.Errorf("capture pictures %q; the Blue Marble reader answers only for %q", raw.Body, Body)200 case raw.AssetSHA256 == "":201 return errors.New("capture names no source digest")202 case raw.Width <= 0 || raw.Height <= 0:203 return errors.New("capture names no source image size")204 case raw.Map.MaxZoom < 1:205 return errors.New("capture declares no pyramid")206 case len(raw.Features.Countries) == 0 || len(raw.Features.Capitals) == 0:207 return errors.New("capture carries no features")208 }209 for _, entry := range raw.Features.Countries {210 if entry.Name == "" || entry.A3 == "" || entry.Continent == "" {211 return fmt.Errorf("country %q carries no identity", entry.Name)212 }213 }214 for _, entry := range raw.Features.Capitals {215 switch {216 case entry.Name == "":217 return errors.New("a capital carries no name")218 case entry.Lat < -90 || entry.Lat > 90 || entry.Lon < -180 || entry.Lon > 180:219 return fmt.Errorf("capital %q sits at %v, %v", entry.Name, entry.Lat, entry.Lon)220 }221 }222 return nil223}224225func named(given, slug string) string {226 if given != "" {227 return given228 }229 return doc.Title(slug)230}