clone

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

feat: let a planet stand beside the games

Dami committed df71489
Nothing in the bundle format was ever about games, and now the
collection proves it: Mars arrives as an .atlas like any other, its
raster from NASA Trek's Viking MDIM 2.1 mosaic and its two thousand
named places from the IAU Gazetteer of Planetary Nomenclature, each
pin carrying its true coordinates, its size, and the origin story of
its name -- the one piece of prose the data files publish.

A Trek mosaic is equirectangular, two tiles wide for every one tall,
so it sits in the pipeline's square world one zoom above its own:
pipeline level z holds Trek level z-1, and the planet fills the top
half of the square the way a narrow wikimap fills part of its own.
Features cross the same synthetic projection every other source uses,
so Mercator's distortion cancels instead of mattering. Trek spells a
tile as matrix/row/column; the tile index records the canonical
zoom/x/y spelling instead, and the crawler is the only place that
knows both, which is what fetchTemplateLevel's second template is for.

The importer is curated the way Piggyback's is: each body names one
verified global mosaic, held against its own WMTS capabilities before
a tile is asked for, and only Mars is curated so far. The Gazetteer
arrives as its shapefile's attribute table, folded of the one row it
publishes twice, and a capture that does not translate never enters
the archive.
commit
df7148951d53dca8012e905ff9668fdf3eaca3be
parent
3cb3b8481c50dab2f9cf7f9bf499af2915feec46
author
Dami <dami.etoile@gmail.com>
11 files changed, +1130 -15
Mcmd/cartograph/sources.go15+
Mcmd/cartograph/sources_test.go24+-
Ainternal/trekmap/trekmap.go360+++++++++++++++++++++++++
Ainternal/trekmap/trekmap_test.go269+++++++++++++++++++
Mtools/crawl/ign.go24+-
Mtools/crawl/main.go5+
Mtools/crawl/pb.go2+-
Atools/crawl/trek.go436++++++++++++++++++++++++++++++
Mtools/generate/main.go4+
Mtools/generate/merge.go2+
Mtools/tiles/main.go4+
M cmd/cartograph/sources.go +15 -0
M cmd/cartograph/sources_test.go +17 -7
@@ -6,19 +6,27 @@ import (
66 )77 88 func TestRegistry(t *testing.T) {9- if len(sources) != 3 {10- t.Fatalf("registry holds %d sources, want 3", len(sources))9+ if len(sources) != 4 {10+ t.Fatalf("registry holds %d sources, want 4", len(sources))1111 }12- for _, slug := range []string{"mapgenie", "ign-wiki", "piggyback"} {12+ for _, slug := range []string{"mapgenie", "ign-wiki", "piggyback", "nasa-trek"} {1313 src, ok := sourceBySlug(slug)1414 if !ok {1515 t.Fatalf("registry misses %s", slug)1616 }17- // All three publish complete maps; they differ in quality per18- // component, never in kind.19- for _, component := range []Component{17+ // Every source publishes a complete map save for icons, which Trek18+ // alone leaves to the viewer's fallback glyphs; the game sources19+ // differ in quality per component, never in kind.20+ wanted := []Component{2021 ComponentRaster, ComponentIcons, ComponentLocations, ComponentMetadata,21- } {22+ }23+ if slug == "nasa-trek" {24+ wanted = []Component{ComponentRaster, ComponentLocations, ComponentMetadata}25+ if src.Components().Has(ComponentIcons) {26+ t.Errorf("%s claims icons it does not fetch", slug)27+ }28+ }29+ for _, component := range wanted {2230 if !src.Components().Has(component) {2331 t.Errorf("%s misses component %s", slug, component)2432 }
@@ -41,6 +49,7 @@ func TestFetchArgs(t *testing.T) {
4149 {"mapgenie", "cyberpunk-2077", []string{"-game", "cyberpunk-2077"}},4250 {"ign-wiki", "cyberpunk-2077/night-city", []string{"-ign", "cyberpunk-2077/night-city"}},4351 {"piggyback", "cyberpunk-2077/night-city", []string{"-piggyback", "cyberpunk-2077/night-city"}},52+ {"nasa-trek", "mars", []string{"-trek", "mars"}},4453 }4554 for _, at := range good {4655 src, _ := sourceBySlug(at.source)
@@ -67,6 +76,7 @@ func TestFetchArgs(t *testing.T) {
6776 {"piggyback", "/night-city"}, // no empty half6877 {"piggyback", "night-city/"},6978 {"piggyback", "Night-City/x"}, // slugs are lowercase79+ {"nasa-trek", "mars/global"}, // a body is one slug7080 }7181 for _, at := range bad {7282 src, _ := sourceBySlug(at.source)
A internal/trekmap/trekmap.go +360 -0
@@ -0,0 +1,360 @@
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.23+package trekmap24+25+import (26+ "encoding/json"27+ "fmt"28+ "math"29+ "sort"30+ "strconv"31+32+ "github.com/FelineStateMachine/atlas/internal/mgdoc"33+)34+35+// Kind names Trek captures in a map's snapshot index.36+const Kind = "trek-map"37+38+// Source is the value a well-formed capture carries in its source field.39+const Source = "nasa-trek"40+41+// Capture is the canonical document the crawler stores: which body and mosaic42+// the raster is, and every named feature the Gazetteer publishes for the body.43+// Fields marshal in declaration order and Normalize sorts the features, so44+// unchanged data always hashes to the capture already archived.45+type Capture struct {46+ Source string `json:"source"`47+ Body string `json:"body"`48+ Layer string `json:"layer"`49+ MapSlug string `json:"mapSlug"`50+ MapTitle string `json:"mapTitle"`51+ Map MapConfig `json:"map"`52+ Features []Feature `json:"features"`53+}54+55+// MapConfig is the pyramid as captured. MaxZoom counts in the pipeline's56+// square world -- the deepest level actually taken, one above the Trek level57+// it came from. LayerTitle names the mosaic the way a person knows it, which58+// is what the viewer's layer picker shows.59+type MapConfig struct {60+ MaxZoom int `json:"maxZoom"`61+ Extension string `json:"extension"`62+ LayerTitle string `json:"layerTitle"`63+}64+65+// Feature is one Gazetteer entry, as the feature list publishes it: the IAU's66+// own feature identifier, planetocentric latitude, east-positive longitude in67+// the Gazetteer's 0..360 spelling, diameter in kilometers, and the origin text68+// explaining the name -- the one piece of prose the data carries.69+type Feature struct {70+ ID int64 `json:"id"`71+ Name string `json:"name"`72+ Type string `json:"type"`73+ Code string `json:"code"`74+ Latitude float64 `json:"latitude"`75+ Longitude float64 `json:"longitude"`76+ DiameterKM float64 `json:"diameterKm"`77+ Origin string `json:"origin,omitempty"`78+}79+80+// Normalize puts the capture into its canonical order. The crawler calls this81+// before marshaling, so the Gazetteer listing its features differently does82+// not masquerade as a new version.83+func (c *Capture) Normalize() {84+ sort.Slice(c.Features, func(a, b int) bool { return c.Features[a].ID < c.Features[b].ID })85+}86+87+// TileSetPath is the layer path tile records group under, matching what88+// tools/tiles recovers from a canonical tile URL: the segments between89+// trek.nasa.gov's /tiles/ marker and the zoom.90+func TileSetPath(body, layer string) string {91+ return body + "/EQ/" + layer92+}93+94+// LevelExtent reports the last tile column and row of one pipeline level: the95+// full width of the world square, and the top half of its height. The crawler96+// asks for exactly these tiles, and Translate declares exactly these bounds,97+// because they are the same call.98+func LevelExtent(zoom int) (maxX, maxY int) {99+ if zoom == 0 {100+ return 0, 0101+ }102+ return 1<<zoom - 1, 1<<(zoom-1) - 1103+}104+105+// MaybeTranslate hands other sources' snapshots through untouched and106+// translates Trek captures.107+func MaybeTranslate(kind string, doc []byte) ([]byte, error) {108+ if kind != Kind {109+ return doc, nil110+ }111+ return Translate(doc)112+}113+114+// Translate turns a capture into the MapGenie-shaped document the pipeline115+// reads, deterministically for a given capture.116+func Translate(doc []byte) ([]byte, error) {117+ var capture Capture118+ if err := json.Unmarshal(doc, &capture); err != nil {119+ return nil, fmt.Errorf("decode trek capture: %w", err)120+ }121+ if capture.Source != Source {122+ return nil, fmt.Errorf("capture source is %q, not %q", capture.Source, Source)123+ }124+ if capture.Body == "" || capture.Layer == "" || capture.MapSlug == "" {125+ return nil, fmt.Errorf("capture names no map")126+ }127+ if capture.Map.MaxZoom < 1 {128+ return nil, fmt.Errorf("capture declares no pyramid")129+ }130+ if len(capture.Features) == 0 {131+ return nil, fmt.Errorf("capture carries no features")132+ }133+ capture.Normalize()134+135+ ids := mgdoc.NewIDSpace()136+ scope := capture.Body + "/" + capture.MapSlug137+ mapID, err := ids.Claim("trek:map:" + scope)138+ if err != nil {139+ return nil, err140+ }141+ gameID, err := ids.Claim("trek:game:" + capture.Body)142+ if err != nil {143+ return nil, err144+ }145+146+ groups, err := buildGroups(&capture, ids, scope)147+ if err != nil {148+ return nil, err149+ }150+151+ out := mgdoc.Map{152+ ID: mapID,153+ Title: title(capture.MapTitle, capture.MapSlug),154+ Slug: capture.MapSlug,155+ // The map opens on the middle of the planet's picture: halfway across156+ // the world, a quarter down it, which is where a 2:1 image's centre157+ // sits in the square.158+ InitialLatitude: mgdoc.SyntheticLatitude(mgdoc.WorldSize / 4),159+ InitialLongitude: mgdoc.SyntheticLongitude(mgdoc.WorldSize / 2),160+ Config: mgdoc.Config{TileSets: []mgdoc.TileSet{tileSet(&capture)}},161+ Game: mgdoc.Game{162+ ID: gameID,163+ // A planet is a "game" to the pipeline, which is the point being164+ // proven: the slug is the body's own lowercase name, free for165+ // another source's capture of the same body to answer to.166+ Title: mgdoc.SpellOut(capture.Body),167+ Slug: capture.Body,168+ },169+ Groups: groups,170+ Regions: []struct{}{},171+ }172+ return json.Marshal(out)173+}174+175+func title(given, slug string) string {176+ if given != "" {177+ return given178+ }179+ return mgdoc.SpellOut(slug)180+}181+182+// tileSet declares the pyramid in the shape tools/tiles expects. Bounds are183+// declared for every level down to zero: a level without them would be184+// measured against the MapGenie window this map does not sit in, and the185+// half-height windows are what tell the frame derivation where the planet186+// actually is.187+func tileSet(capture *Capture) mgdoc.TileSet {188+ set := mgdoc.TileSet{189+ Name: title(capture.Map.LayerTitle, "default"),190+ Path: TileSetPath(capture.Body, capture.Layer),191+ MinZoom: 0,192+ MaxZoom: capture.Map.MaxZoom,193+ Extension: extension(capture.Map.Extension),194+ Bounds: make(map[string]mgdoc.Bound, capture.Map.MaxZoom+1),195+ }196+ for zoom := 0; zoom <= capture.Map.MaxZoom; zoom++ {197+ maxX, maxY := LevelExtent(zoom)198+ set.Bounds[strconv.Itoa(zoom)] = mgdoc.Bound{199+ X: mgdoc.Range{Min: 0, Max: maxX},200+ Y: mgdoc.Range{Min: 0, Max: maxY},201+ }202+ }203+ return set204+}205+206+func extension(given string) string {207+ if given != "" {208+ return given209+ }210+ return "jpg"211+}212+213+// worldPixel lands a feature's planetary coordinates on the picture. The214+// mosaic spans longitude -180..180 west to east and latitude 90..-90 top to215+// bottom; the Gazetteer speaks east-positive 0..360, so longitudes wrap into216+// the mosaic's half-open window first. X crosses the full world square, y217+// only its top half, because that is where a 2:1 image sits in it.218+func worldPixel(longitude, latitude float64) (x, y float64) {219+ wrapped := math.Mod(longitude+180, 360) - 180220+ x = (wrapped + 180) / 360 * mgdoc.WorldSize221+ y = (90 - latitude) / 180 * (mgdoc.WorldSize / 2)222+ return x, y223+}224+225+// buildGroups arranges the Gazetteer's flat feature list the way the pipeline226+// expects: one category per feature type, all under one group. The type's227+// descriptor -- "Crater, craters" names the singular and the plural -- keeps228+// its singular half as the category title and lends its slug as the icon key,229+// so artwork dropped into the archive later attaches without a policy change;230+// until then the viewer's fallback glyph stands in.231+func buildGroups(capture *Capture, ids *mgdoc.IDSpace, scope string) ([]mgdoc.Group, error) {232+ byType := make(map[string][]Feature)233+ var order []string234+ for _, feature := range capture.Features {235+ if err := check(feature); err != nil {236+ return nil, err237+ }238+ if _, seen := byType[feature.Type]; !seen {239+ order = append(order, feature.Type)240+ }241+ byType[feature.Type] = append(byType[feature.Type], feature)242+ }243+ sort.Strings(order)244+245+ categories := make([]mgdoc.Category, 0, len(order))246+ for _, featureType := range order {247+ name := typeName(featureType)248+ categoryID, err := ids.Claim("trek:type:" + scope + ":" + name)249+ if err != nil {250+ return nil, err251+ }252+ category := mgdoc.Category{253+ ID: categoryID,254+ Title: name,255+ Icon: iconKey(name),256+ DisplayType: "markers",257+ Visible: true,258+ Locations: make([]mgdoc.Location, 0, len(byType[featureType])),259+ }260+ for _, feature := range byType[featureType] {261+ locationID, err := ids.Claim("trek:feature:" + strconv.FormatInt(feature.ID, 10))262+ if err != nil {263+ return nil, err264+ }265+ x, y := worldPixel(feature.Longitude, feature.Latitude)266+ category.Locations = append(category.Locations, mgdoc.Location{267+ ID: locationID,268+ Title: feature.Name,269+ Description: describe(feature),270+ Latitude: mgdoc.SyntheticLatitude(y),271+ Longitude: mgdoc.SyntheticLongitude(x),272+ })273+ }274+ categories = append(categories, category)275+ }276+277+ groupID, err := ids.Claim("trek:group:" + scope)278+ if err != nil {279+ return nil, err280+ }281+ return []mgdoc.Group{{ID: groupID, Title: "Nomenclature", Categories: categories}}, nil282+}283+284+func check(feature Feature) error {285+ switch {286+ case feature.ID <= 0:287+ return fmt.Errorf("feature %q has no identifier", feature.Name)288+ case feature.Name == "":289+ return fmt.Errorf("feature %d has no name", feature.ID)290+ case feature.Type == "":291+ return fmt.Errorf("feature %q has no type", feature.Name)292+ case feature.Latitude < -90 || feature.Latitude > 90:293+ return fmt.Errorf("feature %q sits at latitude %v", feature.Name, feature.Latitude)294+ case feature.Longitude < 0 || feature.Longitude > 360:295+ return fmt.Errorf("feature %q sits at longitude %v", feature.Name, feature.Longitude)296+ }297+ return nil298+}299+300+// iconKey spells a category title the way icon files are named, so artwork301+// for "Albedo Feature" lands at albedo-feature like every other source's.302+func iconKey(name string) string {303+ out := make([]rune, 0, len(name))304+ for _, r := range name {305+ switch {306+ case r >= 'a' && r <= 'z' || r >= '0' && r <= '9':307+ out = append(out, r)308+ case r >= 'A' && r <= 'Z':309+ out = append(out, r+'a'-'A')310+ default:311+ if len(out) > 0 && out[len(out)-1] != '-' {312+ out = append(out, '-')313+ }314+ }315+ }316+ for len(out) > 0 && out[len(out)-1] == '-' {317+ out = out[:len(out)-1]318+ }319+ return string(out)320+}321+322+// typeName keeps the singular half of the Gazetteer's "Crater, craters"323+// descriptor; a descriptor without the plural half is already the name.324+func typeName(descriptor string) string {325+ for at, r := range descriptor {326+ if r == ',' {327+ return descriptor[:at]328+ }329+ }330+ return descriptor331+}332+333+// describe writes the pin's card: the feature's real place on the planet, its334+// size when the Gazetteer gives one, and the origin of its name. The card is335+// where the true coordinates survive for a reader, since the pin itself336+// stores only synthetic ones.337+func describe(feature Feature) string {338+ text := place(feature.Latitude, feature.Longitude)339+ if feature.DiameterKM > 0 {340+ text += fmt.Sprintf(" · %s km across", trimmed(feature.DiameterKM))341+ }342+ if feature.Origin != "" {343+ text += " — " + feature.Origin344+ }345+ return text346+}347+348+func place(latitude, longitude float64) string {349+ ns := "N"350+ if latitude < 0 {351+ ns, latitude = "S", -latitude352+ }353+ return fmt.Sprintf("%s°%s %s°E", trimmed(latitude), ns, trimmed(longitude))354+}355+356+// trimmed spells a value to two decimals and no further, dropping the357+// trailing zeroes a card does not need.358+func trimmed(value float64) string {359+ return strconv.FormatFloat(math.Round(value*100)/100, 'f', -1, 64)360+}
A internal/trekmap/trekmap_test.go +269 -0
@@ -0,0 +1,269 @@
1+package trekmap2+3+import (4+ "bytes"5+ "encoding/json"6+ "math"7+ "strconv"8+ "strings"9+ "testing"10+11+ "github.com/FelineStateMachine/atlas/internal/mgdoc"12+)13+14+// fixture is a small capture exercising everything Translate decides: two15+// feature types, real Gazetteer coordinates east and west of the meridian,16+// both hemispheres, and a feature with no origin text.17+func fixture() Capture {18+ return Capture{19+ Source: Source,20+ Body: "mars",21+ Layer: "Mars_Viking_MDIM21_ClrMosaic_global_232m",22+ MapSlug: "global",23+ MapTitle: "Global",24+ Map: MapConfig{25+ MaxZoom: 6,26+ Extension: "jpg",27+ LayerTitle: "Viking MDIM 2.1",28+ },29+ Features: []Feature{30+ {ID: 4453, Name: "Olympus Mons", Type: "Mons, montes", Code: "MO",31+ Latitude: 18.6528, Longitude: 226.1975, DiameterKM: 610.13,32+ Origin: "Classical albedo feature name."},33+ {ID: 2071, Name: "Gale", Type: "Crater, craters", Code: "AA",34+ Latitude: -5.3672, Longitude: 137.811, DiameterKM: 154.084,35+ Origin: "Walter F.; Australian astronomer (1865-1945)."},36+ {ID: 2432, Name: "Hellas Planitia", Type: "Planitia, planitiae", Code: "PL",37+ Latitude: -42.4301, Longitude: 70.5025, DiameterKM: 2299.16},38+ },39+ }40+}41+42+func translate(t *testing.T, capture Capture) mgdoc.Map {43+ t.Helper()44+ doc, err := json.Marshal(capture)45+ if err != nil {46+ t.Fatal(err)47+ }48+ out, err := Translate(doc)49+ if err != nil {50+ t.Fatal(err)51+ }52+ var m mgdoc.Map53+ if err := json.Unmarshal(out, &m); err != nil {54+ t.Fatal(err)55+ }56+ return m57+}58+59+func TestTranslateIsDeterministic(t *testing.T) {60+ doc, _ := json.Marshal(fixture())61+ first, err := Translate(doc)62+ if err != nil {63+ t.Fatal(err)64+ }65+66+ // The same features arriving in a different order are the same capture.67+ shuffled := fixture()68+ shuffled.Features[0], shuffled.Features[2] = shuffled.Features[2], shuffled.Features[0]69+ reordered, _ := json.Marshal(shuffled)70+ second, err := Translate(reordered)71+ if err != nil {72+ t.Fatal(err)73+ }74+ if !bytes.Equal(first, second) {75+ t.Fatal("the same capture translated to different bytes")76+ }77+}78+79+func TestTranslateShape(t *testing.T) {80+ m := translate(t, fixture())81+82+ if m.Slug != "global" || m.Title != "Global" {83+ t.Fatalf("map identity: %q %q", m.Slug, m.Title)84+ }85+ if m.Game.Slug != "mars" || m.Game.Title != "Mars" {86+ t.Fatalf("game identity: %q %q", m.Game.Slug, m.Game.Title)87+ }88+89+ if len(m.Config.TileSets) != 1 {90+ t.Fatalf("tile sets: %d", len(m.Config.TileSets))91+ }92+ set := m.Config.TileSets[0]93+ if set.Path != "mars/EQ/Mars_Viking_MDIM21_ClrMosaic_global_232m" {94+ t.Fatalf("tile set path: %q", set.Path)95+ }96+ if set.Name != "Viking MDIM 2.1" || set.Extension != "jpg" {97+ t.Fatalf("tile set: %+v", set)98+ }99+ if set.MinZoom != 0 || set.MaxZoom != 6 {100+ t.Fatalf("zoom range: %d..%d", set.MinZoom, set.MaxZoom)101+ }102+ // The planet fills the width of the world square and the top half of its103+ // height, at every level of the pyramid.104+ for zoom := 0; zoom <= 6; zoom++ {105+ bounds, ok := set.Bounds[strconv.Itoa(zoom)]106+ if !ok {107+ t.Fatalf("no bounds declared for zoom %d", zoom)108+ }109+ wantX, wantY := 1<<zoom-1, 1<<zoom/2-1110+ if zoom == 0 {111+ wantX, wantY = 0, 0112+ }113+ if bounds.X.Min != 0 || bounds.Y.Min != 0 || bounds.X.Max != wantX || bounds.Y.Max != wantY {114+ t.Fatalf("zoom %d bounds: %+v", zoom, bounds)115+ }116+ }117+118+ if len(m.Groups) != 1 || m.Groups[0].Title != "Nomenclature" {119+ t.Fatalf("groups: %+v", m.Groups)120+ }121+ titles := make([]string, 0, 3)122+ for _, category := range m.Groups[0].Categories {123+ titles = append(titles, category.Title+"/"+category.Icon)124+ }125+ want := "Crater/crater Mons/mons Planitia/planitia"126+ if strings.Join(titles, " ") != want {127+ t.Fatalf("categories: %v, want %s", titles, want)128+ }129+}130+131+// TestSyntheticCoordinatesRoundTrip runs the synthetic latitude and longitude132+// through the viewer's projection and expects each feature's equirectangular133+// pixel back: longitude across the full 8192-pixel width, latitude down the134+// 4096-pixel top half, with the Gazetteer's 0..360 longitudes wrapped into135+// the mosaic's -180..180 window.136+func TestSyntheticCoordinatesRoundTrip(t *testing.T) {137+ m := translate(t, fixture())138+139+ pixel := func(longitude, latitude float64) (float64, float64) {140+ if longitude >= 180 {141+ longitude -= 360142+ }143+ return (longitude + 180) / 360 * mgdoc.WorldSize,144+ (90 - latitude) / 180 * mgdoc.WorldSize / 2145+ }146+ byTitle := map[string][2]float64{}147+ for _, feature := range fixture().Features {148+ x, y := pixel(feature.Longitude, feature.Latitude)149+ byTitle[feature.Name] = [2]float64{x, y}150+ }151+152+ seen := 0153+ for _, group := range m.Groups {154+ for _, category := range group.Categories {155+ for _, location := range category.Locations {156+ want, ok := byTitle[location.Title]157+ if !ok {158+ continue159+ }160+ seen++161+ x := mgdoc.ProjectX(location.Longitude, mgdoc.SourceZoom, 0)162+ y := mgdoc.ProjectY(location.Latitude, mgdoc.SourceZoom, 0)163+ if math.Abs(x-want[0]) > 1e-6 || math.Abs(y-want[1]) > 1e-6 {164+ t.Fatalf("%s projects to %.6f,%.6f, want %.6f,%.6f",165+ location.Title, x, y, want[0], want[1])166+ }167+ }168+ }169+ }170+ if seen != len(byTitle) {171+ t.Fatalf("saw %d of %d features", seen, len(byTitle))172+ }173+}174+175+func TestDescriptionsCarryTheRealPlace(t *testing.T) {176+ m := translate(t, fixture())177+ descriptions := map[string]string{}178+ for _, group := range m.Groups {179+ for _, category := range group.Categories {180+ for _, location := range category.Locations {181+ descriptions[location.Title] = location.Description182+ }183+ }184+ }185+ olympus := descriptions["Olympus Mons"]186+ if olympus != "18.65°N 226.2°E · 610.13 km across — Classical albedo feature name." {187+ t.Fatalf("Olympus Mons card: %q", olympus)188+ }189+ // A feature without origin text still says where and how big it is.190+ hellas := descriptions["Hellas Planitia"]191+ if hellas != "42.43°S 70.5°E · 2299.16 km across" {192+ t.Fatalf("Hellas card: %q", hellas)193+ }194+ for title, description := range descriptions {195+ if strings.Contains(description, "http") {196+ t.Fatalf("%s card carries a link: %q", title, description)197+ }198+ }199+}200+201+func TestIdentifiersAreStable(t *testing.T) {202+ first := translate(t, fixture())203+ second := translate(t, fixture())204+ if first.ID != second.ID || first.ID <= 0 || first.ID > math.MaxInt32 {205+ t.Fatalf("map id: %d then %d", first.ID, second.ID)206+ }207+ firstLocations := locationIDs(first)208+ secondLocations := locationIDs(second)209+ for title, id := range firstLocations {210+ if secondLocations[title] != id {211+ t.Fatalf("%s renumbered: %d then %d", title, id, secondLocations[title])212+ }213+ if id <= 0 || id > math.MaxInt32 {214+ t.Fatalf("%s id %d leaves the positive int31 range", title, id)215+ }216+ }217+}218+219+func locationIDs(m mgdoc.Map) map[string]int64 {220+ out := make(map[string]int64)221+ for _, group := range m.Groups {222+ for _, category := range group.Categories {223+ for _, location := range category.Locations {224+ out[location.Title] = location.ID225+ }226+ }227+ }228+ return out229+}230+231+func TestTranslateRejectsBadCaptures(t *testing.T) {232+ cases := []struct {233+ name string234+ mutate func(*Capture)235+ mention string236+ }{237+ {"foreign source", func(c *Capture) { c.Source = "someone-else" }, "source"},238+ {"no layer", func(c *Capture) { c.Layer = "" }, "no map"},239+ {"no pyramid", func(c *Capture) { c.Map.MaxZoom = 0 }, "pyramid"},240+ {"no features", func(c *Capture) { c.Features = nil }, "features"},241+ {"unnamed feature", func(c *Capture) { c.Features[0].Name = "" }, "name"},242+ {"feature without id", func(c *Capture) { c.Features[0].ID = 0 }, "identifier"},243+ {"latitude off the planet", func(c *Capture) { c.Features[0].Latitude = 91 }, "latitude"},244+ {"longitude off the planet", func(c *Capture) { c.Features[0].Longitude = -5 }, "longitude"},245+ {"doubled feature", func(c *Capture) { c.Features[1].ID = c.Features[0].ID }, "collision"},246+ }247+ for _, test := range cases {248+ capture := fixture()249+ test.mutate(&capture)250+ doc, err := json.Marshal(capture)251+ if err != nil {252+ t.Fatal(err)253+ }254+ if _, err := Translate(doc); err == nil || !strings.Contains(err.Error(), test.mention) {255+ t.Fatalf("%s: %v", test.name, err)256+ }257+ }258+}259+260+func TestMaybeTranslatePassesOthersThrough(t *testing.T) {261+ doc := []byte(`{"id": 5, "title": "As Captured"}`)262+ out, err := MaybeTranslate("map", doc)263+ if err != nil {264+ t.Fatal(err)265+ }266+ if !bytes.Equal(out, doc) {267+ t.Fatal("a MapGenie snapshot was rewritten")268+ }269+}
M tools/crawl/ign.go +17 -7
@@ -351,7 +351,7 @@ func captureIGNTiles(
351351 for zoom := 0; zoom <= deepest; zoom++ {352352 maxX, maxY := ignmap.LevelExtent(capture.Map.Width, capture.Map.Height, zoom)353353 window := tileWindow{minX: 0, minY: 0, maxX: maxX, maxY: maxY}354- results, err := fetchTemplateLevel(ctx, fetcher, o, template, "", setID, extension,354+ results, err := fetchTemplateLevel(ctx, fetcher, o, template, "", "", setID, extension,355355 mapDir, zoom, window.tiles(), index)356356 if err != nil {357357 return stats, err
@@ -374,11 +374,18 @@ func captureIGNTiles(
374374 // {z}/{x}/{y} template, in whatever punctuation the source spells the tokens.375375 // The hashes of held tiles are recorded alongside fetched ones, so a resumed376376 // crawl sees the same picture of the level a fresh one would.377+//378+// A source that spells its addresses differently from the pipeline -- Trek379+// names the row before the column, one zoom down -- passes a second template380+// naming where the bytes actually live; the first template stays the address381+// of record, the one resume checks and every reader of the archive derives382+// the layer from. An empty fetchTemplate means the record address is the383+// fetch address, which is every other source.377384 func fetchTemplateLevel(378385 ctx context.Context,379386 fetcher *fetcher,380387 o options,381- template, referer string,388+ template, fetchTemplate, referer string,382389 setID int64,383390 extension, mapDir string,384391 zoom int,
@@ -392,16 +399,19 @@ func fetchTemplateLevel(
392399 var failure error393400 gate := make(chan struct{}, o.concurrency)394401 395- replacer := func(x, y int) string {402+ replacer := func(template string, x, y int) string {396403 return strings.NewReplacer(397404 "{z}", strconv.Itoa(zoom),398405 "{x}", strconv.Itoa(x),399406 "{y}", strconv.Itoa(y),400407 ).Replace(template)401408 }409+ if fetchTemplate == "" {410+ fetchTemplate = template411+ }402412 403413 for _, coordinate := range wanted {404- url := replacer(coordinate[0], coordinate[1])414+ url := replacer(template, coordinate[0], coordinate[1])405415 path := filepath.Join(mapDir, "tiles",406416 "set-"+strconv.FormatInt(setID, 10),407417 strconv.Itoa(zoom), strconv.Itoa(coordinate[0]),
@@ -427,11 +437,11 @@ func fetchTemplateLevel(
427437 428438 group.Add(1)429439 gate <- struct{}{}430- go func(coordinate [2]int, url, path string) {440+ go func(coordinate [2]int, url, fetchURL, path string) {431441 defer group.Done()432442 defer func() { <-gate }()433443 434- body, contentType, err := fetcher.getReferred(ctx, url, referer)444+ body, contentType, err := fetcher.getReferred(ctx, fetchURL, referer)435445 mu.Lock()436446 defer mu.Unlock()437447 if err != nil {
@@ -469,7 +479,7 @@ func fetchTemplateLevel(
469479 result.fetched++470480 result.bytes += int64(len(body))471481 result.hashes[coordinate] = hash472- }(coordinate, url, path)482+ }(coordinate, url, replacer(fetchTemplate, coordinate[0], coordinate[1]), path)473483 }474484 group.Wait()475485 return result, failure
M tools/crawl/main.go +5 -0
M tools/crawl/pb.go +1 -1
A tools/crawl/trek.go +436 -0
@@ -0,0 +1,436 @@
1+// The Trek mode fills the archive from two public planetary publications at2+// once: NASA Trek's global tile mosaics for the raster, and the IAU Gazetteer3+// of Planetary Nomenclature's downloadable feature list for the pins. Both4+// are plainly published for taking -- the mosaic behind an open WMTS5+// endpoint, the features as a shapefile whose attribute table carries name,6+// place, size, and the origin story of every name -- and nothing else is7+// touched: no page prose, nothing the data files do not carry.8+//9+// This is the curated-bodies importer, and says so. A Trek mosaic's tiling is10+// asserted from its own WMTS capabilities before anything is fetched, but11+// which mosaic represents a body is an editorial choice; each body in the12+// table names one verified global equirectangular layer, and an unknown body13+// is refused at the door rather than captured through an unverified one.14+package main15+16+import (17+ "archive/zip"18+ "bytes"19+ "context"20+ "encoding/binary"21+ "encoding/json"22+ "encoding/xml"23+ "errors"24+ "fmt"25+ "hash/fnv"26+ "io"27+ "path/filepath"28+ "strconv"29+ "strings"30+31+ "github.com/FelineStateMachine/atlas/internal/mgdoc"32+ "github.com/FelineStateMachine/atlas/internal/trekmap"33+)34+35+const trekTiles = "https://trek.nasa.gov/tiles"36+37+// trekDefaultZoom is how deep a crawl goes unasked, in the pipeline's square38+// world where level z holds Trek's z-1. Level 6 is Trek's 5: 2048 tiles for39+// the deepest level and a third of that above it, a courteous take of a40+// pyramid whose deepest published levels run to hundreds of thousands.41+const trekDefaultZoom = 642+43+// trekBody is one body the importer knows: how Trek's paths spell it, which44+// global mosaic pictures it, and where the Gazetteer's feature list for it45+// lives.46+type trekBody struct {47+ pathName string48+ layer string49+ layerTitle string50+ portal string51+ gazetteer string52+}53+54+var trekBodies = map[string]trekBody{55+ "mars": {56+ pathName: "Mars",57+ layer: "Mars_Viking_MDIM21_ClrMosaic_global_232m",58+ layerTitle: "Viking MDIM 2.1",59+ portal: "https://trek.nasa.gov/mars",60+ gazetteer: "https://asc-planetarynames-data.s3.us-west-2.amazonaws.com/MARS_nomenclature_center_pts.zip",61+ },62+}63+64+func runTrek(ctx context.Context, fetcher *fetcher, o options) error {65+ slug := o.trek66+ body, known := trekBodies[slug]67+ if !known {68+ bodies := make([]string, 0, len(trekBodies))69+ for name := range trekBodies {70+ bodies = append(bodies, name)71+ }72+ return fmt.Errorf("the Trek importer knows only %s: each body names one verified "+73+ "global mosaic, and an unverified one could hang every pin on the wrong pixel",74+ strings.Join(bodies, ", "))75+ }76+77+ layerBase := trekTiles + "/" + body.pathName + "/EQ/" + body.layer78+ published, err := fetchTrekPyramid(ctx, fetcher, layerBase)79+ if err != nil {80+ return err81+ }82+ deepest := published83+ if o.maxZoom > 0 {84+ deepest = min(deepest, o.maxZoom)85+ } else {86+ deepest = min(deepest, trekDefaultZoom)87+ }88+89+ features, err := fetchGazetteer(ctx, fetcher, body.gazetteer)90+ if err != nil {91+ return err92+ }93+94+ capture := &trekmap.Capture{95+ Source: trekmap.Source,96+ Body: slug,97+ Layer: body.layer,98+ MapSlug: "global",99+ MapTitle: "Global",100+ Map: trekmap.MapConfig{101+ MaxZoom: deepest,102+ Extension: "jpg",103+ LayerTitle: body.layerTitle,104+ },105+ Features: features,106+ }107+ capture.Normalize()108+ raw, err := json.MarshalIndent(capture, "", " ")109+ if err != nil {110+ return err111+ }112+ // A capture that does not translate would poison the archive for both113+ // tools that read it, so it is refused here, while the problem is one114+ // crawl old rather than buried in history.115+ if _, err := trekmap.Translate(raw); err != nil {116+ return fmt.Errorf("capture does not translate: %w", err)117+ }118+119+ gameID := trekArchiveID("trek:" + slug)120+ mapID := trekArchiveID("trek:" + slug + "/" + capture.MapSlug)121+ game := &apiGameFull{ID: gameID, Title: mgdoc.SpellOut(slug),122+ Config: apiGameConfig{URL: body.portal}}123+ full := &apiMapFull{ID: mapID, Title: capture.MapTitle, Slug: capture.MapSlug}124+ // The directory says where the capture came from even though the game125+ // keeps its plain title: another source picturing the same body would126+ // live in its own directory, and the two must never resolve to one.127+ gameDirectory := resolveGameDirectory(o.archive,128+ &apiGameFull{ID: gameID, Title: "NASA Trek " + game.Title})129+ mapDirectory := resolveMapDirectory(o.archive, gameDirectory, full)130+ mapDir := filepath.Join(o.archive, mapDirectory)131+132+ fmt.Printf("\n== NASA Trek %s / %s (%d features, pyramid z1-%d of %d published)\n",133+ game.Title, capture.MapTitle, len(capture.Features), deepest, published)134+135+ if o.dryRun {136+ fmt.Printf(" would write capture to %s\n", mapDir)137+ } else if err := writeSnapshotRecord(mapDir, raw, trekmap.Kind, mapID, body.gazetteer); err != nil {138+ return err139+ }140+141+ index, err := readTileIndex(mapDir)142+ if err != nil {143+ return err144+ }145+ index.mapID = mapID146+ stats, err := captureTrekTiles(ctx, fetcher, o, slug, body, layerBase, deepest, mapDir, index)147+ if err != nil {148+ return err149+ }150+ if o.dryRun {151+ return nil152+ }153+ if err := writeTileIndex(mapDir, index); err != nil {154+ return err155+ }156+ if err := writeMapMetadata(mapDir, game, full, index, len(capture.Features)); err != nil {157+ return err158+ }159+ if err := registerMap(o.archive, game, full, gameDirectory, mapDirectory, "nasa-trek"); err != nil {160+ return err161+ }162+ fmt.Printf(" %d fetched · %d held · %d not published · %d failed · %.1f MB new\n",163+ stats.fetched, stats.skipped, stats.absent, stats.failed, float64(stats.bytes)/1e6)164+ return nil165+}166+167+// wmtsCapabilities is the slice of a layer's WMTS document the crawl stands168+// on: the tile matrices, whose sizes and origin say how the mosaic is tiled.169+type wmtsCapabilities struct {170+ Contents struct {171+ TileMatrixSets []struct {172+ Matrices []struct {173+ TopLeftCorner string `xml:"TopLeftCorner"`174+ TileWidth int `xml:"TileWidth"`175+ TileHeight int `xml:"TileHeight"`176+ MatrixWidth float64 `xml:"MatrixWidth"`177+ MatrixHeight float64 `xml:"MatrixHeight"`178+ } `xml:"TileMatrix"`179+ } `xml:"TileMatrixSet"`180+ } `xml:"Contents"`181+}182+183+// fetchTrekPyramid reads the layer's own account of its tiling and holds it184+// to the shape the translator promises: an equirectangular pyramid anchored185+// at longitude -180, latitude 90, two tiles wide for every one tall, in186+// 256-pixel tiles. It returns the deepest pipeline zoom published -- one187+// above the deepest Trek matrix, since the pipeline's square world sits one188+// level over the 2:1 mosaic.189+func fetchTrekPyramid(ctx context.Context, f *fetcher, layerBase string) (int, error) {190+ capsURL := layerBase + "/1.0.0/WMTSCapabilities.xml"191+ body, _, err := f.get(ctx, capsURL)192+ if err != nil {193+ return 0, fmt.Errorf("capabilities: %w", err)194+ }195+ var caps wmtsCapabilities196+ if err := xml.Unmarshal(body, &caps); err != nil {197+ return 0, fmt.Errorf("decode %s: %w", capsURL, err)198+ }199+ if len(caps.Contents.TileMatrixSets) != 1 {200+ return 0, fmt.Errorf("%s declares %d tile matrix sets, not the one global scheme",201+ capsURL, len(caps.Contents.TileMatrixSets))202+ }203+ matrices := caps.Contents.TileMatrixSets[0].Matrices204+ if len(matrices) == 0 {205+ return 0, fmt.Errorf("%s declares no tile matrices", capsURL)206+ }207+ for level, matrix := range matrices {208+ wide, tall := float64(int(2)<<level), float64(int(1)<<level)209+ corner := strings.Fields(matrix.TopLeftCorner)210+ if len(corner) != 2 || corner[0] != "-180.0" || corner[1] != "90.0" {211+ return 0, fmt.Errorf("level %d anchors at %q, not the -180/90 corner the "+212+ "projection assumes", level, matrix.TopLeftCorner)213+ }214+ if matrix.TileWidth != 256 || matrix.TileHeight != 256 ||215+ matrix.MatrixWidth != wide || matrix.MatrixHeight != tall {216+ return 0, fmt.Errorf("level %d is %vx%v tiles of %dx%d px; the projection "+217+ "assumes the 2:1 equirectangular scheme", level,218+ matrix.MatrixWidth, matrix.MatrixHeight, matrix.TileWidth, matrix.TileHeight)219+ }220+ }221+ return len(matrices), nil222+}223+224+// captureTrekTiles takes the pyramid level by level, whole: a global mosaic225+// has no empty space to prune around. Trek spells a tile as matrix/row/226+// column, one zoom below and with its axes named the other way round from227+// the pipeline's zoom/x/y -- so each level fetches under Trek's spelling and228+// records under the canonical one, which is the address every reader of the229+// archive derives the layer and its window from.230+func captureTrekTiles(231+ ctx context.Context,232+ fetcher *fetcher,233+ o options,234+ slug string,235+ body trekBody,236+ layerBase string,237+ deepest int,238+ mapDir string,239+ index *tileIndex,240+) (captureStats, error) {241+ var stats captureStats242+ scope := trekmap.TileSetPath(slug, body.layer)243+ setID := index.tileSetID(scope)244+ fmt.Printf(" layer %q z1-%d\n", scope, deepest)245+246+ for zoom := 1; zoom <= deepest; zoom++ {247+ maxX, maxY := trekmap.LevelExtent(zoom)248+ window := tileWindow{minX: 0, minY: 0, maxX: maxX, maxY: maxY}249+ record := trekTiles + "/" + scope + "/{z}/{x}/{y}.jpg"250+ fetch := layerBase + "/1.0.0/default/default028mm/" +251+ strconv.Itoa(zoom-1) + "/{y}/{x}.jpg"252+ results, err := fetchTemplateLevel(ctx, fetcher, o, record, fetch, "", setID, "jpg",253+ mapDir, zoom, window.tiles(), index)254+ if err != nil {255+ return stats, err256+ }257+ stats.merge(results)258+ fmt.Printf(" z%-2d %4d fetched · %4d held · %4d not published\n",259+ zoom, results.fetched, results.skipped, results.absent)260+ // The bundle is built from the deepest complete level. Holes there mean261+ // the mosaic is thinner than its capabilities claim, which deserves262+ // more than a counter in the summary line.263+ if zoom == deepest && results.absent > 0 {264+ fmt.Printf(" WARNING: %d tiles of the deepest level are not published; "+265+ "the bundle will be built from a shallower level\n", results.absent)266+ }267+ }268+ return stats, nil269+}270+271+// fetchGazetteer takes the body's feature list as the Gazetteer publishes it272+// -- a zipped shapefile -- and reads the attribute table inside, which273+// carries everything the capture keeps: names, places, sizes, feature types,274+// and the origin text explaining each name.275+func fetchGazetteer(ctx context.Context, f *fetcher, url string) ([]trekmap.Feature, error) {276+ body, _, err := f.get(ctx, url)277+ if err != nil {278+ return nil, fmt.Errorf("gazetteer: %w", err)279+ }280+ archive, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))281+ if err != nil {282+ return nil, fmt.Errorf("read gazetteer archive: %w", err)283+ }284+ for _, file := range archive.File {285+ if !strings.HasSuffix(strings.ToLower(file.Name), ".dbf") {286+ continue287+ }288+ reader, err := file.Open()289+ if err != nil {290+ return nil, err291+ }292+ table, err := io.ReadAll(reader)293+ reader.Close()294+ if err != nil {295+ return nil, err296+ }297+ return parseNomenclature(table)298+ }299+ return nil, fmt.Errorf("%s holds no attribute table", url)300+}301+302+// parseNomenclature reads the shapefile's dBASE attribute table. The format303+// is fixed-width rows behind a field directory; the Gazetteer writes it in304+// UTF-8, per the encoding sidecar in the same archive.305+func parseNomenclature(table []byte) ([]trekmap.Feature, error) {306+ if len(table) < 32 {307+ return nil, errors.New("attribute table is truncated")308+ }309+ records := int(binary.LittleEndian.Uint32(table[4:8]))310+ headerSize := int(binary.LittleEndian.Uint16(table[8:10]))311+ recordSize := int(binary.LittleEndian.Uint16(table[10:12]))312+313+ type column struct {314+ name string315+ start int316+ length int317+ }318+ var columns []column319+ // Each row leads with a deletion flag byte, so fields start at one.320+ at := 1321+ for pos := 32; ; pos += 32 {322+ if pos >= len(table) || pos >= headerSize {323+ return nil, errors.New("attribute table's field directory never ends")324+ }325+ if table[pos] == 0x0d {326+ break327+ }328+ if pos+32 > len(table) {329+ return nil, errors.New("attribute table is truncated inside a field")330+ }331+ name := string(bytes.TrimRight(table[pos:pos+11], "\x00"))332+ length := int(table[pos+16])333+ columns = append(columns, column{name: name, start: at, length: length})334+ at += length335+ }336+ if at != recordSize {337+ return nil, fmt.Errorf("fields span %d bytes of a %d-byte record", at, recordSize)338+ }339+ if headerSize+records*recordSize > len(table) {340+ return nil, fmt.Errorf("attribute table holds fewer than its %d records", records)341+ }342+343+ wanted := map[string]bool{344+ "name": true, "type": true, "code": true, "origin": true,345+ "diameter": true, "center_lat": true, "center_lon": true, "link": true,346+ }347+ features := make([]trekmap.Feature, 0, records)348+ for number := range records {349+ row := table[headerSize+number*recordSize:][:recordSize]350+ if row[0] == '*' {351+ continue352+ }353+ cells := make(map[string]string, len(wanted))354+ for _, col := range columns {355+ if !wanted[col.name] {356+ continue357+ }358+ cells[col.name] = strings.TrimSpace(string(row[col.start : col.start+col.length]))359+ }360+ feature, err := composeFeature(cells)361+ if err != nil {362+ return nil, fmt.Errorf("record %d: %w", number, err)363+ }364+ features = append(features, feature)365+ }366+ return dedupeFeatures(features)367+}368+369+// dedupeFeatures folds the table's own repeats. The Mars list has been seen370+// carrying one crater twice, byte for byte; a doubled row saying the same371+// thing is the source's hygiene, not a different feature, but two rows372+// claiming one identifier for different facts would be, and are refused.373+func dedupeFeatures(features []trekmap.Feature) ([]trekmap.Feature, error) {374+ seen := make(map[int64]trekmap.Feature, len(features))375+ kept := features[:0]376+ for _, feature := range features {377+ previous, held := seen[feature.ID]378+ if !held {379+ seen[feature.ID] = feature380+ kept = append(kept, feature)381+ continue382+ }383+ if previous != feature {384+ return nil, fmt.Errorf("feature %d is both %q and %q", feature.ID,385+ previous.Name, feature.Name)386+ }387+ }388+ return kept, nil389+}390+391+// composeFeature turns one attribute row into the capture's terms. The392+// Gazetteer's own feature identifier comes off the record's link, which is393+// the only place the table spells it.394+func composeFeature(cells map[string]string) (trekmap.Feature, error) {395+ _, tail, found := strings.Cut(cells["link"], "/Feature/")396+ if !found {397+ return trekmap.Feature{}, fmt.Errorf("no feature identifier in link %q", cells["link"])398+ }399+ id, err := strconv.ParseInt(tail, 10, 64)400+ if err != nil {401+ return trekmap.Feature{}, fmt.Errorf("feature identifier %q: %w", tail, err)402+ }403+ latitude, err := strconv.ParseFloat(cells["center_lat"], 64)404+ if err != nil {405+ return trekmap.Feature{}, fmt.Errorf("latitude %q: %w", cells["center_lat"], err)406+ }407+ longitude, err := strconv.ParseFloat(cells["center_lon"], 64)408+ if err != nil {409+ return trekmap.Feature{}, fmt.Errorf("longitude %q: %w", cells["center_lon"], err)410+ }411+ diameter := 0.0412+ if cells["diameter"] != "" {413+ if diameter, err = strconv.ParseFloat(cells["diameter"], 64); err != nil {414+ return trekmap.Feature{}, fmt.Errorf("diameter %q: %w", cells["diameter"], err)415+ }416+ }417+ return trekmap.Feature{418+ ID: id,419+ Name: cells["name"],420+ Type: cells["type"],421+ Code: cells["code"],422+ Latitude: latitude,423+ Longitude: longitude,424+ DiameterKM: diameter,425+ Origin: cells["origin"],426+ }, nil427+}428+429+// trekArchiveID numbers Trek bodies and maps in the archive's registers, one430+// bit above Piggyback's so every source's ids stay apart; see ignArchiveID431+// for why the range stays far below a float64's integers.432+func trekArchiveID(seed string) int64 {433+ hash := fnv.New32a()434+ _, _ = hash.Write([]byte(seed))435+ return int64(hash.Sum32()&0x7fffffff) | 1<<34436+}
M tools/generate/main.go +4 -0
M tools/generate/merge.go +2 -0
M tools/tiles/main.go +4 -0