The capture marries a second public-domain publication to the raster: Natural Earth's 1:110m cultural vectors, at a tagged release, each file held to a pinned digest exactly as the image is. The crawler distills them into the capture body -- names, codes, continents, label points and rings, coordinates verbatim -- and the reader arranges them the way the legend reads: the primary capitals as one point collection per continent under a Capitals heading, wearing the library's monument glyph, and the country borders after them as one quiet area collection, labels held back until a border is asked about. Each capital is a member of the country it stands on. Twenty-nine are microstates the 1:110m borders leave out -- Singapore, Monaco, the island states -- and the nearest drawn ground decides their continent, computed from the captured polygons at translate time the way the city source computes its watershed join. Boundaries and capital designations travel exactly as Natural Earth draws them, de facto, with no editorial judgement added here. The whole vector half costs the bundle about a hundred kilobytes: 202 pins, 177 areas, one icon, 21 MiB all told, unchanged pyramid carried straight through.
- commit
- 7da80dc7524f6188e88cb925e8ffcf44f3add940
- parent
- 1171ebc4feb8eb6f0f545dd7994bc60eca1ecac9
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 2.3 of 3, Jev's estimate
| M | docs/ | 34 | +++-- |
| M | included/ | 20 | ++- |
| R | included/ | bin | |
| M | internal/ | 203 | ++++++++++++++++++++++++++--- |
| M | internal/ | 89 | ++++++++++++- |
| M | internal/ | 36 | +++++ |
| A | internal/ | 209 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 40 | +++++- |
| M | internal/ | 80 | +++++++++++- |
| M | main_test.go | 36 | ++++- |
@@ -285,13 +285,19 @@ Nomenclature's feature list for the same body.
285285 ### 2.4 The Blue Marble reader286286 287287 `internal/generate/sources/nasabluemarble` reads the included Earth volume's288-one capture: NASA Earth Observatory's Blue Marble Next Generation base map, a289-single pinned publication rather than a live endpoint.290-291-- Capture kind `blue-marble-map`, one per capture of the pinned image.292-- The volume is its picture. One world (`earth`), one lens (`Blue Marble`), no293- collections and no features: pins, layers and enrichment are later sources'294- work, folded in by the enrich lane.288+one capture: NASA Earth Observatory's Blue Marble Next Generation base map289+married to Natural Earth's 1:110m cultural vectors — pinned publications290+rather than live endpoints.291+292+- Capture kind `blue-marble-map`, one per capture of the pinned files.293+- One world (`earth`), one lens (`Blue Marble`), and the lightest cut of294+ ground truth: the primary capitals as a point collection per continent,295+ each pin a member of the country it stands in, and the country borders as296+ one quiet area collection. Continents are Natural Earth's own filing; a297+ capital whose microstate the 1:110m borders leave out takes the nearest298+ drawn ground's continent, computed from the captured polygons at translate299+ time the way the city source computes its watershed join. Everything else300+ is later sources' work, folded in by the enrich lane.295301 - The coordinate design is the shared whole-sphere window NASA Trek also rides296302 (`doc.EquirectPx` and friends): a 2:1 global image filling the top half of297303 the world square, declared as registered conventions — surface, projection,@@ -303,17 +309,19 @@ single pinned publication rather than a live endpoint.
303309 cut under, so a policy change reads as a new capture rather than the same304310 one wearing new bytes.305311 - The gates: another kind's capture, another source's name, another body, a306- missing digest, a missing size, or a capture declaring no pyramid are312+ missing digest, a missing size, a capture declaring no pyramid or carrying313+ no features, a country with no identity, or a capital off the planet are307314 refused by name.308315 - `idSpace: "derived"`: nothing in the publication numbers a base map.309316 310317 The crawler half (`internal/generate/crawl/bluemarble.go`) is the one place311-the pinned URL, digest and capture time are spelled. It fetches the source312-image exactly once into the staged archive, refuses any digest but the pinned318+the pinned URLs, digests and capture time are spelled. It fetches each pinned319+file exactly once into the staged archive, refuses any digest but the pinned313320 one, cuts the pyramid's deepest level through the deterministic fixed-point314-Catmull-Rom resampler beside it, and records the capture under the pinned315-time, so the archive — and everything stamped downstream of it — reproduces on316-any machine. `included/README.md` is the recipe and the provenance, and321+Catmull-Rom resampler beside it, distills the Natural Earth files into the322+capture body — names, codes, continents, label points and rings, coordinates323+verbatim — and records the capture under the pinned time, so the archive — and324+everything stamped downstream of it — reproduces on any machine. `included/README.md` is the recipe and the provenance, and317325 `make included-earth` is the whole run.318326 319327 ### 2.5 The IGN reader@@ -9,8 +9,12 @@ installation — opening, rendering, analysis, export, registry ordering — rea
99 the installed file, never the embedded copy. `atlas serve` and the CLI embed1010 nothing: they use exactly the registry the operator gives them.1111 12-The volume is raster-only: one world (`earth`), one lens (`Blue Marble`), no13-collections, no features, and no runtime network dependency of any kind.12+The volume is one world (`earth`) with one lens (`Blue Marble`) and the13+lightest cut of ground truth: the primary capitals as a point collection per14+continent (202 pins, each linked to the country it stands in), and the country15+borders as one quiet area collection (177 areas) — enough to jump somewhere16+real and see the legend, pins and ground working. No runtime network17+dependency of any kind.1418 1519 ## Provenance1620 @@ -23,12 +27,20 @@ collections, no features, and no runtime network dependency of any kind.
2327 | Source SHA-256 | `d225f1f35a6448a4d1d8f6de6e48f3433e470085b70a35800e64f384f269a7b0` |2428 | First captured | `2026-08-03T15:27:26Z` |2529 | Credit | NASA Earth Observatory |30+| Features | Natural Earth 1:110m cultural vectors, v5.1.2 — public domain |31+| Borders file | <https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_110m_admin_0_countries.geojson> |32+| Borders SHA-256 | `6866c877d39cba9c357620878839b336d569f8c662d3cfab4cb1dbe2d39c977f` |33+| Places file | <https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_110m_populated_places_simple.geojson> |34+| Places SHA-256 | `0dbd25c9ad8bd797ddf164b067f563be5c16be2c002254eb594862377963f9dc` |2635 2736 NASA imagery is free of copyright. NASA's media guidelines ask that the credit2837 be preserved, that no NASA logo be used, and that no NASA endorsement be2938 implied; the bundle's provenance ledger carries the credit, and nothing here30-does the other two. No source URL appears inside the bundle — format v3's31-runtime-URL prohibition holds for this volume exactly as for any other.39+does the other two. Natural Earth is in the public domain; its boundaries and40+its primary-capital designations are carried exactly as that project draws41+them, de facto, with no editorial judgement added here. No source URL appears42+inside the bundle — format v3's runtime-URL prohibition holds for this volume43+exactly as for any other.3244 3345 ## Derivation3446 Binary file.
@@ -57,6 +57,18 @@ type blueMarblePin struct {
5757 // Quality is the JPEG setting the reference tiles are encoded at. It is5858 // spelled here, once, because two runs of one pipeline may not disagree.5959 Quality int60+ // FeaturesEdition names the vector publication the capture marries to the61+ // raster, and Borders and Places pin its two files: country polygons and62+ // populated places, each held to its own digest exactly as the image is.63+ FeaturesEdition string64+ Borders, Places blueMarbleAsset65+}66+67+// blueMarbleAsset is one pinned file: where it is, and what its bytes must68+// hash to.69+type blueMarbleAsset struct {70+ Asset string71+ SHA256 string6072 }6173 6274 // blueMarble is the shipped pin: Blue Marble Next Generation with topography@@ -68,10 +80,23 @@ var blueMarble = blueMarblePin{
6880 Page: "https://science.nasa.gov/earth/earth-observatory/blue-marble-next-generation/base-topography-bathymetry/",6981 Asset: "https://assets.science.nasa.gov/content/dam/science/esd/eo/images/bmng/bmng-topography-bathymetry/july/world.topo.bathy.200407.3x21600x10800.jpg",7082 SHA256: "d225f1f35a6448a4d1d8f6de6e48f3433e470085b70a35800e64f384f269a7b0",71- CapturedAt: "2026-08-03T15:27:26Z",83+ CapturedAt: "2026-08-03T16:21:07Z",7284 Width: 21600,7385 Height: 10800,7486 Quality: 85,87+ // The feature data is Natural Earth, public domain, at a tagged release:88+ // country polygons at 1:110m -- the light cut, made for whole-planet maps89+ // -- and the populated places its capitals are read from. Boundaries and90+ // capital designations are carried as Natural Earth draws them, de facto.91+ FeaturesEdition: "Natural Earth 1:110m cultural vectors v5.1.2",92+ Borders: blueMarbleAsset{93+ Asset: "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_110m_admin_0_countries.geojson",94+ SHA256: "6866c877d39cba9c357620878839b336d569f8c662d3cfab4cb1dbe2d39c977f",95+ },96+ Places: blueMarbleAsset{97+ Asset: "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_110m_populated_places_simple.geojson",98+ SHA256: "0dbd25c9ad8bd797ddf164b067f563be5c16be2c002254eb594862377963f9dc",99+ },75100 }76101 77102 // blueMarbleResampler names the cut for the capture body: the resampler beside@@ -133,34 +158,48 @@ func (c blueMarbleCrawler) Crawl(ctx context.Context, run Run) error {
133158 if err != nil {134159 return err135160 }136- source, err := c.source(ctx, run, worldDir, log)161+ source, err := c.asset(ctx, run, worldDir, blueMarble.Asset, blueMarble.SHA256, ".jpg", log)162+ if err != nil {163+ return err164+ }165+ borders, err := c.asset(ctx, run, worldDir, blueMarble.Borders.Asset, blueMarble.Borders.SHA256, ".geojson", log)166+ if err != nil {167+ return err168+ }169+ places, err := c.asset(ctx, run, worldDir, blueMarble.Places.Asset, blueMarble.Places.SHA256, ".geojson", log)137170 if err != nil {138171 return err139172 }140- if err := writeBlueMarble(run.Archive, worldDir, world.ID, blueMarble, source, log); err != nil {173+ features, err := distillBlueMarbleFeatures(blueMarble, borders, places)174+ if err != nil {175+ return err176+ }177+ if err := writeBlueMarble(run.Archive, worldDir, world.ID, blueMarble, source, features, log); err != nil {141178 return err142179 }143180 return nil144181 }145182 146-// source answers the pinned image's bytes: the copy already kept beside the183+// asset answers one pinned file's bytes: the copy already kept beside the147184 // capture when there is one, the origin exactly once when there is not. Either148185 // way the bytes are held to the pinned digest before anything is derived from149186 // them.150-func (c blueMarbleCrawler) source(ctx context.Context, run Run, worldDir string, log *slog.Logger) ([]byte, error) {151- cached := blueMarbleSourcePath(worldDir, blueMarble)187+func (c blueMarbleCrawler) asset(188+ ctx context.Context, run Run, worldDir, url, sha256, extension string, log *slog.Logger,189+) ([]byte, error) {190+ cached := filepath.Join(worldDir, "source", sha256+extension)152191 if data, err := os.ReadFile(cached); err == nil {153- if err := verifyBlueMarble(data, blueMarble); err != nil {192+ if err := verifyBlueMarble(data, sha256); err != nil {154193 return nil, fmt.Errorf("cached source %s: %w", cached, err)155194 }156195 log.Info("source already captured", logging.Path(cached), "bytes", len(data))157196 return data, nil158197 }159- data, _, err := run.Fetch.Get(ctx, blueMarble.Asset, nil)198+ data, _, err := run.Fetch.Get(ctx, url, nil)160199 if err != nil {161- return nil, fmt.Errorf("fetch %s: %w", blueMarble.Asset, err)200+ return nil, fmt.Errorf("fetch %s: %w", url, err)162201 }163- if err := verifyBlueMarble(data, blueMarble); err != nil {202+ if err := verifyBlueMarble(data, sha256); err != nil {164203 return nil, err165204 }166205 if err := writeFile(cached, data); err != nil {@@ -170,29 +209,114 @@ func (c blueMarbleCrawler) source(ctx context.Context, run Run, worldDir string,
170209 return data, nil171210 }172211 173-// blueMarbleSourcePath is where the downloaded source sits: beside the world's174-// captures, content-addressed by the pinned digest.175-func blueMarbleSourcePath(worldDir string, pin blueMarblePin) string {176- return filepath.Join(worldDir, "source", pin.SHA256+".jpg")177-}178-179-// verifyBlueMarble holds bytes to the pin. It runs before any decode: bytes180-// that are not the pinned publication are not decoded, not derived from, and181-// not archived.182-func verifyBlueMarble(data []byte, pin blueMarblePin) error {183- if sum := Hash(data); sum != pin.SHA256 {212+// verifyBlueMarble holds bytes to a pinned digest. It runs before any decode:213+// bytes that are not the pinned publication are not decoded, not derived from,214+// and not archived.215+func verifyBlueMarble(data []byte, sha256 string) error {216+ if sum := Hash(data); sum != sha256 {184217 return fmt.Errorf(185218 "source digest mismatch: fetched bytes hash to %s where the pin requires %s; "+186- "refusing changed upstream bytes", sum, pin.SHA256)219+ "refusing changed upstream bytes", sum, sha256)187220 }188221 return nil189222 }190223 224+// distillBlueMarbleFeatures reads the two verified Natural Earth files down to225+// what the capture keeps: each country's name, code, continent, label point226+// and rings, and each primary capital's name, country and place. Coordinates227+// travel verbatim, as published; putting them on the picture is the reader's228+// work. Order is the publication's own.229+func distillBlueMarbleFeatures(pin blueMarblePin, borders, places []byte) (blueMarbleFeatures, error) {230+ out := blueMarbleFeatures{231+ Edition: pin.FeaturesEdition,232+ BordersSHA256: pin.Borders.SHA256,233+ PlacesSHA256: pin.Places.SHA256,234+ }235+236+ var countries struct {237+ Features []struct {238+ Properties struct {239+ Name string `json:"NAME"`240+ A3 string `json:"ADM0_A3"`241+ Continent string `json:"CONTINENT"`242+ LabelLon float64 `json:"LABEL_X"`243+ LabelLat float64 `json:"LABEL_Y"`244+ } `json:"properties"`245+ Geometry struct {246+ Type string `json:"type"`247+ Coordinates json.RawMessage `json:"coordinates"`248+ } `json:"geometry"`249+ } `json:"features"`250+ }251+ if err := json.Unmarshal(borders, &countries); err != nil {252+ return out, fmt.Errorf("decode country borders: %w", err)253+ }254+ for _, feature := range countries.Features {255+ country := blueMarbleCountry{256+ Name: feature.Properties.Name,257+ A3: feature.Properties.A3,258+ Continent: feature.Properties.Continent,259+ LabelLon: feature.Properties.LabelLon,260+ LabelLat: feature.Properties.LabelLat,261+ }262+ switch feature.Geometry.Type {263+ case "Polygon":264+ var rings [][][2]float64265+ if err := json.Unmarshal(feature.Geometry.Coordinates, &rings); err != nil {266+ return out, fmt.Errorf("country %q: %w", country.Name, err)267+ }268+ country.Polygons = [][][][2]float64{rings}269+ case "MultiPolygon":270+ if err := json.Unmarshal(feature.Geometry.Coordinates, &country.Polygons); err != nil {271+ return out, fmt.Errorf("country %q: %w", country.Name, err)272+ }273+ default:274+ return out, fmt.Errorf("country %q draws a %s; borders are ground",275+ country.Name, feature.Geometry.Type)276+ }277+ out.Countries = append(out.Countries, country)278+ }279+280+ var placed struct {281+ Features []struct {282+ Properties struct {283+ Class string `json:"featurecla"`284+ Name string `json:"name"`285+ Country string `json:"adm0name"`286+ A3 string `json:"adm0_a3"`287+ Lat float64 `json:"latitude"`288+ Lon float64 `json:"longitude"`289+ } `json:"properties"`290+ } `json:"features"`291+ }292+ if err := json.Unmarshal(places, &placed); err != nil {293+ return out, fmt.Errorf("decode populated places: %w", err)294+ }295+ for _, feature := range placed.Features {296+ // Only the primary designations: the alternates are second seats and297+ // former capitals, which is more editorial weight than a demo carries.298+ if feature.Properties.Class != "Admin-0 capital" {299+ continue300+ }301+ out.Capitals = append(out.Capitals, blueMarbleCapital{302+ Name: feature.Properties.Name,303+ Country: feature.Properties.Country,304+ A3: feature.Properties.A3,305+ Lat: feature.Properties.Lat,306+ Lon: feature.Properties.Lon,307+ })308+ }309+ return out, nil310+}311+191312 // writeBlueMarble derives the capture from verified source bytes: the image192313 // decoded, held to its declared size, cut to the capture level through the193314 // deterministic resampler, tiled, and recorded -- and the capture body written194315 // last, carrying the product's identity and the policy the cut was made under.195-func writeBlueMarble(store *Archive, worldDir string, worldID int64, pin blueMarblePin, source []byte, log *slog.Logger) error {316+func writeBlueMarble(317+ store *Archive, worldDir string, worldID int64,318+ pin blueMarblePin, source []byte, features blueMarbleFeatures, log *slog.Logger,319+) error {196320 decoded, kind, err := image.Decode(bytes.NewReader(source))197321 if err != nil {198322 return fmt.Errorf("decode source: %w", err)@@ -259,6 +383,7 @@ func writeBlueMarble(store *Archive, worldDir string, worldID int64, pin blueMar
259383 Resampler: blueMarbleResampler,260384 JPEGQuality: pin.Quality,261385 },386+ Features: features,262387 }, "", " ")263388 if err != nil {264389 return err@@ -299,6 +424,7 @@ type blueMarbleCapture struct {
299424 Height int `json:"height"`300425 Map blueMarbleMosaic `json:"map"`301426 Derive blueMarbleDerivation `json:"derive"`427+ Features blueMarbleFeatures `json:"features"`302428 }303429 304430 type blueMarbleMosaic struct {@@ -312,6 +438,37 @@ type blueMarbleDerivation struct {
312438 JPEGQuality int `json:"jpegQuality"`313439 }314440 441+// blueMarbleFeatures is the vector half of the capture: the publication it was442+// distilled from, the digests its files carried, and the distillation itself.443+type blueMarbleFeatures struct {444+ Edition string `json:"edition"`445+ BordersSHA256 string `json:"bordersSha256"`446+ PlacesSHA256 string `json:"placesSha256"`447+ Countries []blueMarbleCountry `json:"countries"`448+ Capitals []blueMarbleCapital `json:"capitals"`449+}450+451+// blueMarbleCountry is one country as the capture keeps it: identity, the452+// continent the publication files it under, its label point, and its rings --453+// polygons, then rings, then positions, longitude first, all verbatim.454+type blueMarbleCountry struct {455+ Name string `json:"name"`456+ A3 string `json:"a3"`457+ Continent string `json:"continent"`458+ LabelLon float64 `json:"labelLon"`459+ LabelLat float64 `json:"labelLat"`460+ Polygons [][][][2]float64 `json:"polygons"`461+}462+463+// blueMarbleCapital is one primary capital as the capture keeps it.464+type blueMarbleCapital struct {465+ Name string `json:"name"`466+ Country string `json:"country"`467+ A3 string `json:"a3"`468+ Lat float64 `json:"lat"`469+ Lon float64 `json:"lon"`470+}471+315472 func blueMarbleArchiveID(name string) int64 {316473 return int64(fnv32a(name)&0x7fffffff) | blueMarbleIDBit317474 }@@ -27,19 +27,79 @@ func TestBlueMarblePinsTheSpecifiedPublication(t *testing.T) {
2727 if blueMarble.SHA256 != "d225f1f35a6448a4d1d8f6de6e48f3433e470085b70a35800e64f384f269a7b0" {2828 t.Errorf("digest %q is not the pinned digest", blueMarble.SHA256)2929 }30- if blueMarble.CapturedAt != "2026-08-03T15:27:26Z" {30+ if blueMarble.CapturedAt != "2026-08-03T16:21:07Z" {3131 t.Errorf("capture time %q is not the recorded first capture", blueMarble.CapturedAt)3232 }3333 if blueMarble.Width != 21600 || blueMarble.Height != 10800 {3434 t.Errorf("source size %d×%d is not the published image's", blueMarble.Width, blueMarble.Height)3535 }36+ if blueMarble.Borders.SHA256 != "6866c877d39cba9c357620878839b336d569f8c662d3cfab4cb1dbe2d39c977f" ||37+ blueMarble.Places.SHA256 != "0dbd25c9ad8bd797ddf164b067f563be5c16be2c002254eb594862377963f9dc" {38+ t.Error("the Natural Earth digests are not the pinned ones")39+ }40+ if !strings.Contains(blueMarble.Borders.Asset, "/v5.1.2/") ||41+ !strings.Contains(blueMarble.Places.Asset, "/v5.1.2/") ||42+ !strings.Contains(blueMarble.FeaturesEdition, "v5.1.2") {43+ t.Error("the feature pins do not agree on the tagged edition")44+ }45+}46+47+// TestBlueMarbleDistillsTheFeatureFiles: the distillation keeps exactly what48+// the capture needs -- names, codes, continents, label points, rings49+// normalized to multipolygons, and only the primary capitals -- with every50+// coordinate verbatim.51+func TestBlueMarbleDistillsTheFeatureFiles(t *testing.T) {52+ borders := []byte(`{"features":[53+ {"properties":{"NAME":"Vale","ADM0_A3":"VAL","CONTINENT":"Europe","LABEL_X":10.5,"LABEL_Y":50.25},54+ "geometry":{"type":"Polygon","coordinates":[[[5,45],[15,45],[15,55],[5,45]]]}},55+ {"properties":{"NAME":"Mar","ADM0_A3":"MAR2","CONTINENT":"Oceania","LABEL_X":150,"LABEL_Y":-20},56+ "geometry":{"type":"MultiPolygon","coordinates":[[[[145,-25],[155,-25],[155,-15],[145,-25]]]]}}]}`)57+ places := []byte(`{"features":[58+ {"properties":{"featurecla":"Admin-0 capital","name":"Vale City","adm0name":"Vale","adm0_a3":"VAL","latitude":50.1,"longitude":10.2}},59+ {"properties":{"featurecla":"Admin-0 capital alt","name":"Old Seat","adm0name":"Vale","adm0_a3":"VAL","latitude":49,"longitude":9}},60+ {"properties":{"featurecla":"Populated place","name":"Just A Town","adm0name":"Vale","adm0_a3":"VAL","latitude":48,"longitude":8}}]}`)61+ pin := blueMarblePin{FeaturesEdition: "stated",62+ Borders: blueMarbleAsset{SHA256: "bb"}, Places: blueMarbleAsset{SHA256: "pp"}}63+ out, err := distillBlueMarbleFeatures(pin, borders, places)64+ if err != nil {65+ t.Fatal(err)66+ }67+ if out.Edition != "stated" || out.BordersSHA256 != "bb" || out.PlacesSHA256 != "pp" {68+ t.Errorf("the distillation does not carry its provenance: %+v", out)69+ }70+ if len(out.Countries) != 2 {71+ t.Fatalf("%d countries", len(out.Countries))72+ }73+ vale := out.Countries[0]74+ if vale.Name != "Vale" || vale.A3 != "VAL" || vale.Continent != "Europe" ||75+ vale.LabelLon != 10.5 || vale.LabelLat != 50.25 {76+ t.Errorf("country did not travel verbatim: %+v", vale)77+ }78+ if len(vale.Polygons) != 1 || len(vale.Polygons[0]) != 1 || vale.Polygons[0][0][0] != [2]float64{5, 45} {79+ t.Errorf("a Polygon did not normalize to one multipolygon part: %+v", vale.Polygons)80+ }81+ if len(out.Countries[1].Polygons) != 1 {82+ t.Errorf("a MultiPolygon did not pass through: %+v", out.Countries[1].Polygons)83+ }84+ if len(out.Capitals) != 1 || out.Capitals[0].Name != "Vale City" {85+ t.Fatalf("the distillation kept %+v; only primary capitals ride", out.Capitals)86+ }87+ if out.Capitals[0].Lat != 50.1 || out.Capitals[0].Lon != 10.2 {88+ t.Errorf("a capital's coordinates did not travel verbatim: %+v", out.Capitals[0])89+ }90+91+ if _, err := distillBlueMarbleFeatures(pin, []byte(`{"features":[92+ {"properties":{"NAME":"Line","ADM0_A3":"LIN","CONTINENT":"Europe"},93+ "geometry":{"type":"LineString","coordinates":[[0,0],[1,1]]}}]}`), places); err == nil {94+ t.Error("a line among the borders was distilled anyway")95+ }3696 }3797 3898 // TestBlueMarbleRefusesAWrongDigest is the gate the whole capture stands3999 // behind: bytes that do not hash to the pin are refused by name, and bytes that40100 // do pass.41101 func TestBlueMarbleRefusesAWrongDigest(t *testing.T) {42- err := verifyBlueMarble([]byte("not the publication"), blueMarble)102+ err := verifyBlueMarble([]byte("not the publication"), blueMarble.SHA256)43103 if err == nil {44104 t.Fatal("changed upstream bytes were accepted")45105 }@@ -48,8 +108,7 @@ func TestBlueMarbleRefusesAWrongDigest(t *testing.T) {
48108 }49109 50110 stated := []byte("a stated source")51- pin := blueMarblePin{SHA256: Hash(stated)}52- if err := verifyBlueMarble(stated, pin); err != nil {111+ if err := verifyBlueMarble(stated, Hash(stated)); err != nil {53112 t.Errorf("the pinned bytes themselves were refused: %v", err)54113 }55114 }@@ -77,6 +136,26 @@ func statedSource(t *testing.T, width, height int) []byte {
77136 return out.Bytes()78137 }79138 139+// statedFeatures is the smallest sound vector half: two countries on two140+// continents, a capital in one of them, and a microstate capital no border141+// draws.142+func statedFeatures() blueMarbleFeatures {143+ return blueMarbleFeatures{144+ Edition: "stated",145+ BordersSHA256: "bb", PlacesSHA256: "pp",146+ Countries: []blueMarbleCountry{147+ {Name: "Vale", A3: "VAL", Continent: "Europe", LabelLon: 10, LabelLat: 50,148+ Polygons: [][][][2]float64{{{{5, 45}, {15, 45}, {15, 55}, {5, 55}, {5, 45}}}}},149+ {Name: "Mar", A3: "MAR2", Continent: "Oceania", LabelLon: 150, LabelLat: -20,150+ Polygons: [][][][2]float64{{{{145, -25}, {155, -25}, {155, -15}, {145, -15}, {145, -25}}}}},151+ },152+ Capitals: []blueMarbleCapital{153+ {Name: "Vale City", Country: "Vale", A3: "VAL", Lat: 50, Lon: 10},154+ {Name: "Atoll", Country: "Atoll", A3: "ATL", Lat: -18, Lon: 152},155+ },156+ }157+}158+80159 // writeStated runs the capture's write path over a stated pin into a fresh81160 // archive, and answers the world directory it wrote.82161 func writeStated(t *testing.T, pin blueMarblePin, source []byte) (root, worldDir string) {@@ -96,7 +175,7 @@ func writeStated(t *testing.T, pin blueMarblePin, source []byte) (root, worldDir
96175 if err != nil {97176 t.Fatal(err)98177 }99- if err := writeBlueMarble(store, worldDir, 43, pin, source, slog.New(slog.DiscardHandler)); err != nil {178+ if err := writeBlueMarble(store, worldDir, 43, pin, source, statedFeatures(), slog.New(slog.DiscardHandler)); err != nil {100179 t.Fatal(err)101180 }102181 return root, worldDir@@ -27,6 +27,10 @@ type capture struct {
2727 Height int `json:"height"`2828 Map mosaic `json:"map"`2929 Derive derivation `json:"derive"`30+ // Features is the vector half of the capture: country borders and primary31+ // capitals, distilled from Natural Earth's public-domain files at crawl32+ // time, coordinates verbatim.33+ Features features `json:"features"`3034 }3135 3236 // mosaic is the pyramid as captured: the deepest square-world level the source@@ -45,3 +49,35 @@ type derivation struct {
4549 Resampler string `json:"resampler"`4650 JPEGQuality int `json:"jpegQuality"`4751 }52+53+// features is the vector publication the capture marries to the raster: which54+// edition it was, the digests its files carried, and the distillation itself.55+type features struct {56+ Edition string `json:"edition"`57+ BordersSHA256 string `json:"bordersSha256"`58+ PlacesSHA256 string `json:"placesSha256"`59+ Countries []country `json:"countries"`60+ Capitals []capital `json:"capitals"`61+}62+63+// country is one country as the capture keeps it: identity, the continent the64+// publication files it under, its label point, and its rings -- polygons, then65+// rings, then positions, longitude first, split at the antimeridian as the66+// publication splits them.67+type country struct {68+ Name string `json:"name"`69+ A3 string `json:"a3"`70+ Continent string `json:"continent"`71+ LabelLon float64 `json:"labelLon"`72+ LabelLat float64 `json:"labelLat"`73+ Polygons [][][][2]float64 `json:"polygons"`74+}75+76+// capital is one primary capital as the capture keeps it.77+type capital struct {78+ Name string `json:"name"`79+ Country string `json:"country"`80+ A3 string `json:"a3"`81+ Lat float64 `json:"lat"`82+ Lon float64 `json:"lon"`83+}@@ -0,0 +1,209 @@
1+package nasabluemarble2+3+import (4+ "encoding/json"5+ "fmt"6+ "math"7+ "sort"8+ "strconv"9+10+ "github.com/FelineStateMachine/atlas/format/semconv"11+ "github.com/FelineStateMachine/atlas/internal/generate/doc"12+)13+14+// The vector half of the volume: country borders as one quiet area collection,15+// and the primary capitals as one point collection per continent. The16+// arrangement is the capture's own -- continents are the publication's filing,17+// capitals ride in the publication's order -- and the one judgement made here18+// is a join: a capital whose country is not drawn at this scale still belongs19+// to a continent, and the nearest drawn ground says which.20+21+// capitalIcon is the library glyph every capital collection names, resolved to22+// embedded bytes by composition.23+const capitalIcon = "maki/monument"24+25+// collectionsOf arranges the capture's features the way the legend reads:26+// capitals first -- one collection per continent, sorted, under one heading --27+// and the ground they stand on after, as a single quiet area collection.28+func collectionsOf(raw *capture, ids *doc.IDSpace) ([]doc.Collection, error) {29+ countryIDs := make(map[string]int64, len(raw.Features.Countries))30+ for _, entry := range raw.Features.Countries {31+ id, err := ids.Claim("ne:country:" + entry.A3)32+ if err != nil {33+ return nil, err34+ }35+ countryIDs[entry.A3] = id36+ }37+38+ byContinent := make(map[string][]doc.Feature)39+ var order []string40+ for _, entry := range raw.Features.Capitals {41+ continent := continentOf(entry, raw.Features.Countries)42+ id, err := ids.Claim("ne:capital:" + entry.A3 + ":" + doc.Slugify(entry.Name))43+ if err != nil {44+ return nil, err45+ }46+ at := worldPoint(entry.Lon, entry.Lat)47+ if _, seen := byContinent[continent]; !seen {48+ order = append(order, continent)49+ }50+ byContinent[continent] = append(byContinent[continent], doc.Feature{51+ ID: id,52+ Title: entry.Name,53+ Subtitle: entry.Country,54+ At: &at,55+ // The pin stands on its country's ground where that ground is56+ // drawn; a capital whose country is below this scale stands on57+ // open map, member zero.58+ Member: countryIDs[entry.A3],59+ // The coordinates as the publication gives them, verbatim.60+ Attrs: map[string]string{61+ semconv.KeyGeoLat: strconv.FormatFloat(entry.Lat, 'f', -1, 64),62+ semconv.KeyGeoLon: strconv.FormatFloat(entry.Lon, 'f', -1, 64),63+ },64+ })65+ }66+ sort.Strings(order)67+68+ out := make([]doc.Collection, 0, len(order)+1)69+ for _, continent := range order {70+ id, err := ids.Claim("ne:capitals:" + doc.Slugify(continent))71+ if err != nil {72+ return nil, err73+ }74+ out = append(out, doc.Collection{75+ ID: id,76+ Title: continent,77+ Group: "Capitals",78+ Kind: doc.KindPoint,79+ Visible: true,80+ Attrs: map[string]string{81+ semconv.KeyRenderAs: semconv.RenderAsPin,82+ semconv.KeyIconStd: capitalIcon,83+ },84+ Features: byContinent[continent],85+ })86+ }87+88+ countries, err := countriesCollection(raw, ids, countryIDs)89+ if err != nil {90+ return nil, err91+ }92+ return append(out, countries), nil93+}94+95+// countriesCollection is the ground: every country's rings as one area96+// collection, labels quiet so the picture stays a picture until a border is97+// asked about.98+func countriesCollection(raw *capture, ids *doc.IDSpace, countryIDs map[string]int64) (doc.Collection, error) {99+ id, err := ids.Claim("ne:countries")100+ if err != nil {101+ return doc.Collection{}, err102+ }103+ collection := doc.Collection{104+ ID: id,105+ Title: "Countries",106+ Kind: doc.KindArea,107+ Visible: true,108+ Attrs: map[string]string{109+ semconv.KeyLabelPolicy: semconv.LabelQuiet,110+ },111+ Features: make([]doc.Feature, 0, len(raw.Features.Countries)),112+ }113+ for _, entry := range raw.Features.Countries {114+ geometry, err := worldGeometry(entry.Polygons)115+ if err != nil {116+ return doc.Collection{}, fmt.Errorf("country %q: %w", entry.Name, err)117+ }118+ center := worldPoint(entry.LabelLon, entry.LabelLat)119+ collection.Features = append(collection.Features, doc.Feature{120+ ID: countryIDs[entry.A3],121+ Title: entry.Name,122+ Center: ¢er,123+ Geometry: []doc.Geometry{geometry},124+ Attrs: map[string]string{125+ semconv.KeyGeoLat: strconv.FormatFloat(entry.LabelLat, 'f', -1, 64),126+ semconv.KeyGeoLon: strconv.FormatFloat(entry.LabelLon, 'f', -1, 64),127+ },128+ })129+ }130+ return collection, nil131+}132+133+// worldPoint lands published coordinates on the picture. Unlike the shared134+// EquirectWorldPixel it does not wrap: the publication speaks -180..180 and135+// splits its rings at the antimeridian, so the mapping is linear and a vertex136+// pinned to either edge of the world stays on its own edge instead of folding137+// onto the other.138+func worldPoint(lon, lat float64) doc.Position {139+ x := (lon + 180) / 360 * doc.SyntheticWorldSize140+ y := (90 - lat) / 180 * (doc.SyntheticWorldSize / 2)141+ return doc.SyntheticPosition(x, y)142+}143+144+// worldGeometry converts a country's rings into the volume's own space, as one145+// MultiPolygon. Positions are rounded to a millionth of a degree -- far inside146+// a world pixel -- so the payload does not spend fifteen digits saying what147+// six already say.148+func worldGeometry(polygons [][][][2]float64) (doc.Geometry, error) {149+ if len(polygons) == 0 {150+ return doc.Geometry{}, fmt.Errorf("draws no ground")151+ }152+ converted := make([][][][2]float64, len(polygons))153+ for p, polygon := range polygons {154+ if len(polygon) == 0 {155+ return doc.Geometry{}, fmt.Errorf("draws an empty polygon")156+ }157+ converted[p] = make([][][2]float64, len(polygon))158+ for r, ring := range polygon {159+ converted[p][r] = make([][2]float64, len(ring))160+ for v, vertex := range ring {161+ at := worldPoint(vertex[0], vertex[1])162+ converted[p][r][v] = [2]float64{rounded(at.Lng), rounded(at.Lat)}163+ }164+ }165+ }166+ coordinates, err := json.Marshal(converted)167+ if err != nil {168+ return doc.Geometry{}, err169+ }170+ return doc.Geometry{Type: "MultiPolygon", Coordinates: coordinates}, nil171+}172+173+func rounded(value float64) float64 {174+ return math.Round(value*1e6) / 1e6175+}176+177+// continentOf answers which continent a capital belongs to: its country's178+// filing when the country is drawn at this scale, and the nearest drawn179+// ground's otherwise. Twenty-nine capitals are microstates the 1:110m borders180+// leave out -- Singapore, Monaco, the island states -- and the nearest vertex181+// of any drawn country decides for them, computed from the captured polygons182+// at translate time the way the city source computes its watershed join.183+func continentOf(entry capital, countries []country) string {184+ for _, held := range countries {185+ if held.A3 == entry.A3 {186+ return held.Continent187+ }188+ }189+ nearest, best := "", math.Inf(1)190+ scale := math.Cos(entry.Lat * math.Pi / 180)191+ for _, held := range countries {192+ for _, polygon := range held.Polygons {193+ for _, ring := range polygon {194+ for _, vertex := range ring {195+ dLon := math.Abs(vertex[0] - entry.Lon)196+ if dLon > 180 {197+ dLon = 360 - dLon198+ }199+ dLat := vertex[1] - entry.Lat200+ distance := dLat*dLat + dLon*scale*dLon*scale201+ if distance < best {202+ best, nearest = distance, held.Continent203+ }204+ }205+ }206+ }207+ }208+ return nearest209+}@@ -4,10 +4,13 @@
44 // The capture is one pinned publication: Blue Marble Next Generation, NASA55 // Earth Observatory's global, cloud-free, true-color composite of the whole66 // planet. Where the NASA Trek reader marries a mosaic to a gazetteer, this7-// reader carries a picture and nothing else -- the volume it emits holds one8-// world, one lens, no collections and no features. It exists so that Earth can9-// travel as an ordinary raster-only volume: the included build every library10-// starts with, and the ground later readings enrich.7+// 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.1114 //1215 // # The coordinate design1316 //@@ -74,9 +77,12 @@ func (Source) Describe() doc.Provenance {
7477 Name: SourceName,7578 Label: "NASA Earth Observatory",7679 License: "Public domain. NASA imagery carries no copyright; NASA's media guidelines " +77- "ask that NASA Earth Observatory be credited and that no NASA endorsement be implied.",80+ "ask that NASA Earth Observatory be credited and that no NASA endorsement be implied. " +81+ "Natural Earth data is in the public domain.",7882 Attribution: "Blue Marble: Next Generation, a global true-color base map by " +79- "NASA Earth Observatory, from NASA Goddard Space Flight Center imagery.",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.",8086 // Nothing in the publication numbers a base map, so every identity here8187 // is derived from a stable name.8288 IDSpace: doc.IDSpaceDerived,@@ -174,9 +180,12 @@ func (s Source) translateWorld(a *archive.Archive, ref archive.WorldRef, log *sl
174180 TileSet: TileSet,175181 Frame: doc.EquirectFrame(raw.Map.MaxZoom, raw.Map.Extension),176182 }},177- // No collections: the volume is its picture. Features are later178- // sources' work, folded in by the enrich lane.179183 }184+ collections, err := collectionsOf(&raw, ids)185+ if err != nil {186+ return doc.World{}, err187+ }188+ world.Collections = collections180189 log.Debug("world translated", logging.World(world.Slug), "capture", archived.ContentHash)181190 return world, nil182191 }@@ -194,6 +203,21 @@ func check(raw *capture) error {
194203 return errors.New("capture names no source image size")195204 case raw.Map.MaxZoom < 1:196205 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+ }197221 }198222 return nil199223 }@@ -25,6 +25,22 @@ func wellFormed() capture {
2525 Height: 10800,2626 Map: mosaic{MaxZoom: 5, Extension: "jpg", LayerTitle: "Blue Marble"},2727 Derive: derivation{Resampler: "catmull-rom-fixed15", JPEGQuality: 90},28+ Features: features{29+ Edition: "stated",30+ BordersSHA256: "bb", PlacesSHA256: "pp",31+ Countries: []country{32+ {Name: "Vale", A3: "VAL", Continent: "Europe", LabelLon: 10, LabelLat: 50,33+ Polygons: [][][][2]float64{{{{5, 45}, {15, 45}, {15, 55}, {5, 55}, {5, 45}}}}},34+ {Name: "Mar", A3: "MAR2", Continent: "Oceania", LabelLon: 150, LabelLat: -20,35+ Polygons: [][][][2]float64{{{{145, -25}, {155, -25}, {155, -15}, {145, -15}, {145, -25}}}}},36+ },37+ Capitals: []capital{38+ {Name: "Vale City", Country: "Vale", A3: "VAL", Lat: 50, Lon: 10},39+ // A microstate no border draws at this scale: its continent is40+ // the nearest drawn ground's.41+ {Name: "Atoll", Country: "Atoll", A3: "ATL", Lat: -18, Lon: 152},42+ },43+ },2844 }2945 }3046 @@ -133,8 +149,61 @@ func TestTranslateReadsACaptureWhole(t *testing.T) {
133149 }134150 }135151 136- if len(world.Collections) != 0 {137- t.Errorf("the world carries %d collections; a raster-only volume carries none", len(world.Collections))152+ if len(world.Collections) != 3 {153+ t.Fatalf("%d collections, want two continents of capitals and the countries", len(world.Collections))154+ }155+ europe, oceania, countries := world.Collections[0], world.Collections[1], world.Collections[2]156+ if europe.Title != "Europe" || oceania.Title != "Oceania" {157+ t.Errorf("capitals read %q then %q; continents sort", europe.Title, oceania.Title)158+ }159+ for _, held := range []doc.Collection{europe, oceania} {160+ if held.Group != "Capitals" || held.Kind != doc.KindPoint ||161+ held.Attrs[semconv.KeyRenderAs] != semconv.RenderAsPin ||162+ held.Attrs[semconv.KeyIconStd] != capitalIcon {163+ t.Errorf("capitals collection %q is arranged as %+v", held.Title, held.Attrs)164+ }165+ }166+ if countries.Title != "Countries" || countries.Kind != doc.KindArea ||167+ countries.Attrs[semconv.KeyLabelPolicy] != semconv.LabelQuiet {168+ t.Errorf("the ground is arranged as %q %s %+v", countries.Title, countries.Kind, countries.Attrs)169+ }170+ if len(countries.Features) != 2 {171+ t.Fatalf("%d countries", len(countries.Features))172+ }173+174+ vale := countries.Features[0]175+ capitalOfVale := europe.Features[0]176+ if capitalOfVale.Title != "Vale City" || capitalOfVale.Subtitle != "Vale" {177+ t.Errorf("the capital reads %q / %q", capitalOfVale.Title, capitalOfVale.Subtitle)178+ }179+ if capitalOfVale.Member != vale.ID {180+ t.Errorf("the capital stands on member %d, its country is %d", capitalOfVale.Member, vale.ID)181+ }182+ if capitalOfVale.Attrs[semconv.KeyGeoLat] != "50" || capitalOfVale.Attrs[semconv.KeyGeoLon] != "10" {183+ t.Errorf("the published coordinates did not travel verbatim: %v", capitalOfVale.Attrs)184+ }185+ if at := worldPoint(10, 50); capitalOfVale.At == nil || *capitalOfVale.At != at {186+ t.Errorf("the capital stands at %v, want %v", capitalOfVale.At, at)187+ }188+189+ atoll := oceania.Features[0]190+ if atoll.Title != "Atoll" || atoll.Member != 0 {191+ t.Errorf("a microstate capital should stand on open map: %+v", atoll)192+ }193+194+ if len(vale.Geometry) != 1 || vale.Geometry[0].Type != "MultiPolygon" {195+ t.Fatalf("a country draws %+v", vale.Geometry)196+ }197+ var rings [][][][2]float64198+ if err := json.Unmarshal(vale.Geometry[0].Coordinates, &rings); err != nil {199+ t.Fatal(err)200+ }201+ corner := worldPoint(5, 45)202+ if rings[0][0][0] != [2]float64{rounded(corner.Lng), rounded(corner.Lat)} {203+ t.Errorf("the ground's first vertex is %v, want the converted corner %v", rings[0][0][0], corner)204+ }205+ if vale.Center == nil || *vale.Center != worldPoint(10, 50) {206+ t.Errorf("the country frames at %v, want its label point", vale.Center)138207 }139208 140209 // The same archived bytes give the same document: identities included.@@ -161,6 +230,13 @@ func TestTranslateRefusesWhatItCannotStandFor(t *testing.T) {
161230 {"another source's name", CaptureKind, func(c *capture) { c.Source = "nasa-trek" }, "says its source is"},162231 {"another body", CaptureKind, func(c *capture) { c.Body = "mars" }, "answers only for"},163232 {"no pyramid", CaptureKind, func(c *capture) { c.Map.MaxZoom = 0 }, "declares no pyramid"},233+ {"no features", CaptureKind, func(c *capture) { c.Features = features{} }, "carries no features"},234+ {"a country with no identity", CaptureKind, func(c *capture) {235+ c.Features.Countries[0].Continent = ""236+ }, "carries no identity"},237+ {"a capital off the planet", CaptureKind, func(c *capture) {238+ c.Features.Capitals[0].Lat = 91239+ }, "sits at"},164240 {"no digest", CaptureKind, func(c *capture) { c.AssetSHA256 = "" }, "names no source digest"},165241 {"no size", CaptureKind, func(c *capture) { c.Width = 0 }, "names no source image size"},166242 }@@ -69,7 +69,14 @@ type earthPayload struct {
6969 X, Y, Width, Height int7070 } `json:"bounds"`7171 } `json:"lenses"`72- Collections []json.RawMessage `json:"collections"`72+ Collections []struct {73+ Title string `json:"title"`74+ Group string `json:"group"`75+ Kind string `json:"kind"`76+ IconAsset string `json:"iconAsset"`77+ Attrs map[string]string `json:"attrs"`78+ Features []json.RawMessage `json:"features"`79+ } `json:"collections"`7380 Attrs map[string]string `json:"attrs"`7481 Merged []struct {7582 Source string `json:"source"`@@ -110,23 +117,36 @@ func TestIncludedEarthIsAValidOrdinaryBundle(t *testing.T) {
110117 if len(m.Worlds) != 1 || m.Worlds[0].Slug != "earth" || m.Worlds[0].Title != "Earth" {111118 t.Fatalf("worlds %+v, want exactly the one earth", m.Worlds)112119 }113- if m.Worlds[0].Points != 0 || m.Worlds[0].Paths != 0 || m.Worlds[0].Areas != 0 {114- t.Errorf("the manifest counts features on a raster-only world: %+v", m.Worlds[0])120+ if m.Worlds[0].Points != 202 || m.Worlds[0].Paths != 0 || m.Worlds[0].Areas != 177 {121+ t.Errorf("the manifest counts %+v; the demo carries 202 capitals and 177 countries", m.Worlds[0])115122 }116- if m.Version.CreatedAt != "2026-08-03T15:27:26Z" {123+ if m.Version.CreatedAt != "2026-08-03T16:21:07Z" {117124 t.Errorf("createdAt %s is not the pinned capture time", m.Version.CreatedAt)118125 }119126 }120127 121128 // TestIncludedEarthDeclaresTheWholeSphere: one Blue Marble lens over the122-// complete 8192×4096 Earth surface, no collections, and exactly the spherical123-// equirectangular declarations the analysis contracts recognise.129+// complete 8192×4096 Earth surface, the demo's capitals and countries, and130+// exactly the spherical equirectangular declarations the analysis contracts131+// recognise.124132 func TestIncludedEarthDeclaresTheWholeSphere(t *testing.T) {125133 reader := openIncluded(t)126134 payload := readEarthPayload(t, reader)127135 128- if len(payload.Collections) != 0 {129- t.Errorf("%d collections; the included volume is raster-only", len(payload.Collections))136+ if len(payload.Collections) != 7 {137+ t.Fatalf("%d collections, want six continents of capitals and the countries", len(payload.Collections))138+ }139+ for _, held := range payload.Collections[:6] {140+ if held.Group != "Capitals" || held.Kind != "point" || held.IconAsset == "" {141+ t.Errorf("capitals collection %q arrives as %s/%s with icon %q",142+ held.Title, held.Group, held.Kind, held.IconAsset)143+ }144+ }145+ countries := payload.Collections[6]146+ if countries.Title != "Countries" || countries.Kind != "area" ||147+ countries.Attrs[semconv.KeyLabelPolicy] != semconv.LabelQuiet || len(countries.Features) != 177 {148+ t.Errorf("the ground arrives as %q %s (%d features, %v)",149+ countries.Title, countries.Kind, len(countries.Features), countries.Attrs)130150 }131151 if len(payload.Lenses) != 1 {132152 t.Fatalf("%d lenses, want the one Blue Marble", len(payload.Lenses))