clone

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

refactor: give translators one spelling of the MapGenie shape

Dami committed 35a3cd7
The IGN translator carried its own private copy of the document the
pipeline reads, along with the synthetic projection, the identifier
space, and the world constants. A second source would have carried a
second copy of each, and the two would drift. internal/mgdoc now owns
the shape and the shared machinery; translators own only their capture
and their policy. The emitted bytes are unchanged -- the one new field,
a location's description, marshals away when empty -- so existing
captures still translate to the same payloads and the same stamps.
commit
35a3cd723e024ba85af672a141d1da64ac678cf7
parent
8de3de73f137982d492018b6b1e370ad0a2bf6ef
author
Dami <dami.etoile@gmail.com>
4 files changed, +266 -194
Minternal/ignmap/ignmap.go214++++++------------------------
Minternal/ignmap/ignmap_test.go37++----
Ainternal/mgdoc/mgdoc.go150+++++++++++++++++++++
Ainternal/mgdoc/mgdoc_test.go59++++++++
M internal/ignmap/ignmap.go +45 -169
@@ -16,11 +16,12 @@ package ignmap
1616 import (1717 "encoding/json"1818 "fmt"19- "hash/fnv"2019 "math"2120 "sort"2221 "strconv"2322 "strings"23+24+ "github.com/FelineStateMachine/atlas/internal/mgdoc"2425 )2526 2627 // Kind names IGN captures in a map's snapshot index, alongside the "map"
@@ -96,19 +97,9 @@ func (c *Capture) Normalize() {
9697 sort.Slice(c.Markers, func(a, b int) bool { return c.Markers[a].ID < c.Markers[b].ID })9798 }9899 99-// The world every IGN map is translated into: a 32-tile square, 8192 pixels100-// across, whose tile pyramid starts at a single tile. Pins are stored as101-// latitude and longitude and projected by the viewer, so a marker's linear102-// position on the image becomes the synthetic coordinate that projects back103-// onto that exact pixel.104-const (105- sourceZoom = 5106- tileSize = 256107- worldSize = 8192108-)109-110-// MaybeTranslate hands MapGenie snapshots through untouched and translates IGN111-// captures, so the tools that read snapshots stay one call away from either.100+// MaybeTranslate hands other sources' snapshots through untouched and101+// translates IGN captures, so the tools that read snapshots stay one call102+// away from either.112103 func MaybeTranslate(kind string, doc []byte) ([]byte, error) {113104 if kind != Kind {114105 return doc, nil
@@ -136,13 +127,13 @@ func Translate(doc []byte) ([]byte, error) {
136127 }137128 capture.Normalize()138129 139- ids := newIDSpace()130+ ids := mgdoc.NewIDSpace()140131 scope := capture.ObjectSlug + "/" + capture.MapSlug141- mapID, err := ids.claim("ign:map:" + scope)132+ mapID, err := ids.Claim("ign:map:" + scope)142133 if err != nil {143134 return nil, err144135 }145- gameID, err := ids.claim("ign:game:" + capture.ObjectSlug)136+ gameID, err := ids.Claim("ign:game:" + capture.ObjectSlug)146137 if err != nil {147138 return nil, err148139 }
@@ -152,14 +143,14 @@ func Translate(doc []byte) ([]byte, error) {
152143 return nil, err153144 }154145 155- out := mgMap{146+ out := mgdoc.Map{156147 ID: mapID,157148 Title: mapTitle(&capture),158149 Slug: capture.MapSlug,159- InitialLatitude: syntheticLatitude(-capture.Map.InitialLat * worldSize),160- InitialLongitude: syntheticLongitude(capture.Map.InitialLng * worldSize),161- Config: mgConfig{TileSets: []mgTileSet{tileSet(&capture, scope)}},162- Game: mgGame{150+ InitialLatitude: mgdoc.SyntheticLatitude(-capture.Map.InitialLat * mgdoc.WorldSize),151+ InitialLongitude: mgdoc.SyntheticLongitude(capture.Map.InitialLng * mgdoc.WorldSize),152+ Config: mgdoc.Config{TileSets: []mgdoc.TileSet{tileSet(&capture, scope)}},153+ Game: mgdoc.Game{163154 ID: gameID,164155 // The game keeps its plain slug, so captures of the same game from165156 // different sources build bundles that answer for one library entry
@@ -179,31 +170,39 @@ func mapTitle(capture *Capture) string {
179170 if capture.MapTitle != "" {180171 return capture.MapTitle181172 }182- return spellOut(capture.MapSlug)173+ return mgdoc.SpellOut(capture.MapSlug)183174 }184175 185176 // tileSet describes the pyramid the crawler captured in the shape tools/tiles186177 // expects. Bounds are declared for every level: a level without them would be187178 // measured against the MapGenie window this map does not sit in.188-func tileSet(capture *Capture, scope string) mgTileSet {189- set := mgTileSet{179+func tileSet(capture *Capture, scope string) mgdoc.TileSet {180+ set := mgdoc.TileSet{190181 Name: "Default",191182 Path: scope,192183 MinZoom: 0,193184 MaxZoom: capture.Map.MaxZoom,194- Extension: tileExtension(capture.Map.Tileset),195- Bounds: make(map[string]mgBound, capture.Map.MaxZoom+1),185+ Extension: TileExtension(capture.Map.Tileset),186+ Bounds: make(map[string]mgdoc.Bound, capture.Map.MaxZoom+1),196187 }197188 for zoom := 0; zoom <= capture.Map.MaxZoom; zoom++ {198- across := float64(int(1) << zoom)199- set.Bounds[strconv.Itoa(zoom)] = mgBound{200- X: mgRange{Min: 0, Max: lastTile(capture.Map.Width, across)},201- Y: mgRange{Min: 0, Max: lastTile(capture.Map.Height, across)},189+ maxX, maxY := LevelExtent(capture.Map.Width, capture.Map.Height, zoom)190+ set.Bounds[strconv.Itoa(zoom)] = mgdoc.Bound{191+ X: mgdoc.Range{Min: 0, Max: maxX},192+ Y: mgdoc.Range{Min: 0, Max: maxY},202193 }203194 }204195 return set205196 }206197 198+// LevelExtent reports the last tile column and row holding any of the image at199+// a zoom. The crawler asks for exactly these tiles, and Translate declares200+// exactly these bounds, because they are the same call.201+func LevelExtent(width, height float64, zoom int) (maxX, maxY int) {202+ across := float64(int(1) << zoom)203+ return lastTile(width, across), lastTile(height, across)204+}205+207206 func lastTile(extent, across float64) int {208207 last := int(math.Ceil(extent*across)) - 1209208 if last < 0 {
@@ -212,7 +211,8 @@ func lastTile(extent, across float64) int {
212211 return last213212 }214213 215-func tileExtension(template string) string {214+// TileExtension reads the image format off IGN's tile URL template.215+func TileExtension(template string) string {216216 if dot := strings.LastIndex(template, "."); dot >= 0 && dot < len(template)-1 {217217 return strings.ToLower(template[dot+1:])218218 }
@@ -224,7 +224,7 @@ func tileExtension(template string) string {
224224 // rest share one group. A type no marker uses is left out -- an empty category225225 // would only dim the legend -- but it stays in the capture, so a marker226226 // appearing under it later revives the category without a policy change.227-func buildGroups(capture *Capture, ids *idSpace, scope string) ([]mgGroup, error) {227+func buildGroups(capture *Capture, ids *mgdoc.IDSpace, scope string) ([]mgdoc.Group, error) {228228 markersByType := make(map[string][]Marker, len(capture.Types))229229 known := make(map[string]bool, len(capture.Types))230230 for _, t := range capture.Types {
@@ -240,35 +240,35 @@ func buildGroups(capture *Capture, ids *idSpace, scope string) ([]mgGroup, error
240240 markersByType[marker.TypeSlug] = append(markersByType[marker.TypeSlug], marker)241241 }242242 243- grouped := make(map[string][]mgCategory)243+ grouped := make(map[string][]mgdoc.Category)244244 var order []string245245 for _, t := range capture.Types {246246 markers := markersByType[t.TypeSlug]247247 if len(markers) == 0 {248248 continue249249 }250- categoryID, err := ids.claim("ign:type:" + scope + ":" + t.TypeSlug)250+ categoryID, err := ids.Claim("ign:type:" + scope + ":" + t.TypeSlug)251251 if err != nil {252252 return nil, err253253 }254- category := mgCategory{254+ category := mgdoc.Category{255255 ID: categoryID,256256 Title: t.TypeName,257257 Icon: t.TypeSlug,258258 DisplayType: "markers",259259 Visible: true,260- Locations: make([]mgLocation, 0, len(markers)),260+ Locations: make([]mgdoc.Location, 0, len(markers)),261261 }262262 for _, marker := range markers {263- locationID, err := ids.claim("ign:marker:" + marker.ID)263+ locationID, err := ids.Claim("ign:marker:" + marker.ID)264264 if err != nil {265265 return nil, err266266 }267- category.Locations = append(category.Locations, mgLocation{267+ category.Locations = append(category.Locations, mgdoc.Location{268268 ID: locationID,269269 Title: marker.MarkerName,270- Latitude: syntheticLatitude(-marker.Lat * worldSize),271- Longitude: syntheticLongitude(marker.Lng * worldSize),270+ Latitude: mgdoc.SyntheticLatitude(-marker.Lat * mgdoc.WorldSize),271+ Longitude: mgdoc.SyntheticLongitude(marker.Lng * mgdoc.WorldSize),272272 })273273 }274274 key := t.ParentTypeSlug
@@ -278,143 +278,19 @@ func buildGroups(capture *Capture, ids *idSpace, scope string) ([]mgGroup, error
278278 grouped[key] = append(grouped[key], category)279279 }280280 281- groups := make([]mgGroup, 0, len(order))281+ groups := make([]mgdoc.Group, 0, len(order))282282 for _, key := range order {283283 title := "Markers"284284 seed := "ign:group:" + scope285285 if key != "" {286- title = spellOut(key)286+ title = mgdoc.SpellOut(key)287287 seed += ":" + key288288 }289- groupID, err := ids.claim(seed)289+ groupID, err := ids.Claim(seed)290290 if err != nil {291291 return nil, err292292 }293- groups = append(groups, mgGroup{ID: groupID, Title: title, Categories: grouped[key]})293+ groups = append(groups, mgdoc.Group{ID: groupID, Title: title, Categories: grouped[key]})294294 }295295 return groups, nil296296 }297-298-// spellOut turns a slug into a title, which is all the naming a group or an299-// unnamed map gets from IGN.300-func spellOut(slug string) string {301- words := strings.Split(slug, "-")302- for index, word := range words {303- if word != "" {304- words[index] = strings.ToUpper(word[:1]) + word[1:]305- }306- }307- return strings.Join(words, " ")308-}309-310-// Synthetic coordinates invert the viewer's projection, so a marker's linear311-// position on IGN's image, run through the same spherical Mercator every other312-// map uses, lands on the same pixel it was captured at. The world here is the313-// map's own window -- a 32-tile square starting at tile zero -- which the314-// bundle carries as this map's grid.315-func syntheticLongitude(x float64) float64 {316- worldTiles := math.Pow(2, sourceZoom)317- xTile := x / tileSize318- return (xTile/worldTiles)*360 - 180319-}320-321-func syntheticLatitude(y float64) float64 {322- worldTiles := math.Pow(2, sourceZoom)323- yTile := y / tileSize324- return math.Atan(math.Sinh(math.Pi*(1-2*yTile/worldTiles))) * 180 / math.Pi325-}326-327-// idSpace hands out the numeric identifiers the pipeline expects, derived from328-// the stable names IGN publishes, so the same capture always numbers itself the329-// same way. Identifiers stay within a positive int31 -- the packed location330-// format and the viewer both read them as signed 32-bit -- and zero is avoided331-// because the viewer reads zero as absence. A collision between two names is332-// vanishingly unlikely at this scale and loudly fatal rather than silently333-// merging two pins.334-type idSpace struct {335- seen map[int64]string336-}337-338-func newIDSpace() *idSpace {339- return &idSpace{seen: make(map[int64]string)}340-}341-342-func (s *idSpace) claim(seed string) (int64, error) {343- hash := fnv.New32a()344- _, _ = hash.Write([]byte(seed))345- id := int64(hash.Sum32() & 0x7fffffff)346- if id == 0 {347- id = 1348- }349- if holder, taken := s.seen[id]; taken {350- return 0, fmt.Errorf("id collision: %q and %q both hash to %d", holder, seed, id)351- }352- s.seen[id] = seed353- return id, nil354-}355-356-// The MapGenie shapes Translate emits: only the fields tools/tiles and357-// tools/generate actually read, spelled with the snake_case tags their own358-// decoders name.359-type mgMap struct {360- ID int64 `json:"id"`361- Title string `json:"title"`362- Slug string `json:"slug"`363- InitialLatitude float64 `json:"initial_latitude"`364- InitialLongitude float64 `json:"initial_longitude"`365- Config mgConfig `json:"config"`366- Game mgGame `json:"game"`367- Groups []mgGroup `json:"groups"`368- Regions []struct{} `json:"regions"`369-}370-371-type mgGame struct {372- ID int64 `json:"id"`373- Title string `json:"title"`374- Slug string `json:"slug"`375-}376-377-type mgConfig struct {378- TileSets []mgTileSet `json:"tile_sets"`379-}380-381-type mgTileSet struct {382- Name string `json:"name"`383- Path string `json:"path"`384- MinZoom int `json:"min_zoom"`385- MaxZoom int `json:"max_zoom"`386- Extension string `json:"extension"`387- Bounds map[string]mgBound `json:"bounds"`388-}389-390-type mgBound struct {391- X mgRange `json:"x"`392- Y mgRange `json:"y"`393-}394-395-type mgRange struct {396- Min int `json:"min"`397- Max int `json:"max"`398-}399-400-type mgGroup struct {401- ID int64 `json:"id"`402- Title string `json:"title"`403- Categories []mgCategory `json:"categories"`404-}405-406-type mgCategory struct {407- ID int64 `json:"id"`408- Title string `json:"title"`409- Icon string `json:"icon"`410- DisplayType string `json:"display_type"`411- Visible bool `json:"visible"`412- Locations []mgLocation `json:"locations"`413-}414-415-type mgLocation struct {416- ID int64 `json:"id"`417- Title string `json:"title"`418- Latitude float64 `json:"latitude"`419- Longitude float64 `json:"longitude"`420-}
M internal/ignmap/ignmap_test.go +12 -25
@@ -3,10 +3,12 @@ package ignmap
33 import (44 "bytes"55 "encoding/json"6- "hash/fnv"76 "math"7+ "strconv"88 "strings"99 "testing"10+11+ "github.com/FelineStateMachine/atlas/internal/mgdoc"1012 )1113 1214 // fixture is a small capture exercising everything Translate decides: a type
@@ -39,7 +41,7 @@ func fixture() Capture {
3941 }4042 }4143 42-func translate(t *testing.T, capture Capture) mgMap {44+func translate(t *testing.T, capture Capture) mgdoc.Map {4345 t.Helper()4446 doc, err := json.Marshal(capture)4547 if err != nil {
@@ -49,7 +51,7 @@ func translate(t *testing.T, capture Capture) mgMap {
4951 if err != nil {5052 t.Fatal(err)5153 }52- var m mgMap54+ var m mgdoc.Map5355 if err := json.Unmarshal(out, &m); err != nil {5456 t.Fatal(err)5557 }
@@ -99,7 +101,7 @@ func TestTranslateShape(t *testing.T) {
99101 t.Fatalf("zoom range: %d..%d", set.MinZoom, set.MaxZoom)100102 }101103 for zoom := 0; zoom <= 5; zoom++ {102- if _, ok := set.Bounds[itoa(zoom)]; !ok {104+ if _, ok := set.Bounds[strconv.Itoa(zoom)]; !ok {103105 t.Fatalf("no bounds declared for zoom %d", zoom)104106 }105107 }
@@ -140,16 +142,16 @@ func TestSyntheticCoordinatesRoundTrip(t *testing.T) {
140142 m := translate(t, fixture())141143 142144 project := func(latitude, longitude float64) (float64, float64) {143- worldTiles := math.Pow(2, sourceZoom)144- x := ((longitude + 180) / 360) * worldTiles * tileSize145- y := (1 - math.Asinh(math.Tan(latitude*math.Pi/180))/math.Pi) / 2 * worldTiles * tileSize145+ worldTiles := math.Pow(2, mgdoc.SourceZoom)146+ x := ((longitude + 180) / 360) * worldTiles * mgdoc.TileSize147+ y := (1 - math.Asinh(math.Tan(latitude*math.Pi/180))/math.Pi) / 2 * worldTiles * mgdoc.TileSize146148 return x, y147149 }148150 149151 byTitle := map[string][2]float64{150152 "Origin": {0, 0},151- "Centre": {0.4411 * worldSize, 0.5 * worldSize},152- "Corner": {0.8822243 * worldSize, worldSize},153+ "Centre": {0.4411 * mgdoc.WorldSize, 0.5 * mgdoc.WorldSize},154+ "Corner": {0.8822243 * mgdoc.WorldSize, mgdoc.WorldSize},153155 }154156 seen := 0155157 for _, group := range m.Groups {
@@ -191,7 +193,7 @@ func TestIdentifiersAreStable(t *testing.T) {
191193 }192194 }193195 194-func locationIDs(m mgMap) map[string]int64 {196+func locationIDs(m mgdoc.Map) map[string]int64 {195197 out := make(map[string]int64)196198 for _, group := range m.Groups {197199 for _, category := range group.Categories {
@@ -203,17 +205,6 @@ func locationIDs(m mgMap) map[string]int64 {
203205 return out204206 }205207 206-func TestIDCollisionIsFatal(t *testing.T) {207- space := newIDSpace()208- hash := fnv.New32a()209- _, _ = hash.Write([]byte("some-seed"))210- occupied := int64(hash.Sum32() & 0x7fffffff)211- space.seen[occupied] = "already-here"212- if _, err := space.claim("some-seed"); err == nil {213- t.Fatal("claiming an occupied id did not fail")214- }215-}216-217208 func TestTranslateRejectsBadCaptures(t *testing.T) {218209 duplicate := fixture()219210 duplicate.Types = append(duplicate.Types, Type{TypeSlug: "fast-travel", TypeName: "Again"})
@@ -252,7 +243,3 @@ func TestMaybeTranslatePassesMapGenieThrough(t *testing.T) {
252243 t.Fatal("a MapGenie snapshot was rewritten")253244 }254245 }255-256-func itoa(n int) string {257- return string(rune('0' + n))258-}
A internal/mgdoc/mgdoc.go +150 -0
@@ -0,0 +1,150 @@
1+// Package mgdoc is the MapGenie-shaped document the pipeline reads, spelled2+// as Go structs for the translators that emit it. tools/tiles and3+// tools/generate decode this shape from every snapshot; a capture from4+// another source becomes one of these on the way in, and this package is5+// where the translators agree on the fields, the world they project into,6+// and the way stable names become the numeric identifiers the pipeline7+// expects.8+package mgdoc9+10+import (11+ "fmt"12+ "hash/fnv"13+ "math"14+ "strings"15+)16+17+// The world every translated map lives in: a 32-tile square, 8192 pixels18+// across, whose tile pyramid starts at a single tile. Pins are stored as19+// latitude and longitude and projected by the viewer, so a marker's linear20+// position on a source's image becomes the synthetic coordinate that projects21+// back onto that exact pixel.22+const (23+ SourceZoom = 524+ TileSize = 25625+ WorldSize = 819226+)27+28+// Only the fields tools/tiles and tools/generate actually read, spelled with29+// the snake_case tags their own decoders name.30+type Map struct {31+ ID int64 `json:"id"`32+ Title string `json:"title"`33+ Slug string `json:"slug"`34+ InitialLatitude float64 `json:"initial_latitude"`35+ InitialLongitude float64 `json:"initial_longitude"`36+ Config Config `json:"config"`37+ Game Game `json:"game"`38+ Groups []Group `json:"groups"`39+ Regions []struct{} `json:"regions"`40+}41+42+type Game struct {43+ ID int64 `json:"id"`44+ Title string `json:"title"`45+ Slug string `json:"slug"`46+}47+48+type Config struct {49+ TileSets []TileSet `json:"tile_sets"`50+}51+52+type TileSet struct {53+ Name string `json:"name"`54+ Path string `json:"path"`55+ MinZoom int `json:"min_zoom"`56+ MaxZoom int `json:"max_zoom"`57+ Extension string `json:"extension"`58+ Bounds map[string]Bound `json:"bounds"`59+}60+61+type Bound struct {62+ X Range `json:"x"`63+ Y Range `json:"y"`64+}65+66+type Range struct {67+ Min int `json:"min"`68+ Max int `json:"max"`69+}70+71+type Group struct {72+ ID int64 `json:"id"`73+ Title string `json:"title"`74+ Categories []Category `json:"categories"`75+}76+77+type Category struct {78+ ID int64 `json:"id"`79+ Title string `json:"title"`80+ Icon string `json:"icon"`81+ DisplayType string `json:"display_type"`82+ Visible bool `json:"visible"`83+ Locations []Location `json:"locations"`84+}85+86+type Location struct {87+ ID int64 `json:"id"`88+ Title string `json:"title"`89+ Description string `json:"description,omitempty"`90+ Latitude float64 `json:"latitude"`91+ Longitude float64 `json:"longitude"`92+}93+94+// Synthetic coordinates invert the viewer's projection, so a pin's linear95+// position on a source's image, run through the same spherical Mercator every96+// other map uses, lands on the same pixel it was captured at. The world here97+// is the map's own window -- a 32-tile square starting at tile zero -- which98+// the bundle carries as this map's grid.99+func SyntheticLongitude(x float64) float64 {100+ worldTiles := math.Pow(2, SourceZoom)101+ xTile := x / TileSize102+ return (xTile/worldTiles)*360 - 180103+}104+105+func SyntheticLatitude(y float64) float64 {106+ worldTiles := math.Pow(2, SourceZoom)107+ yTile := y / TileSize108+ return math.Atan(math.Sinh(math.Pi*(1-2*yTile/worldTiles))) * 180 / math.Pi109+}110+111+// IDSpace hands out the numeric identifiers the pipeline expects, derived112+// from the stable names a source publishes, so the same capture always113+// numbers itself the same way. Identifiers stay within a positive int31 --114+// the packed location format and the viewer both read them as signed 32-bit115+// -- and zero is avoided because the viewer reads zero as absence. A116+// collision between two names is vanishingly unlikely at this scale and117+// loudly fatal rather than silently merging two pins.118+type IDSpace struct {119+ seen map[int64]string120+}121+122+func NewIDSpace() *IDSpace {123+ return &IDSpace{seen: make(map[int64]string)}124+}125+126+func (s *IDSpace) Claim(seed string) (int64, error) {127+ hash := fnv.New32a()128+ _, _ = hash.Write([]byte(seed))129+ id := int64(hash.Sum32() & 0x7fffffff)130+ if id == 0 {131+ id = 1132+ }133+ if holder, taken := s.seen[id]; taken {134+ return 0, fmt.Errorf("id collision: %q and %q both hash to %d", holder, seed, id)135+ }136+ s.seen[id] = seed137+ return id, nil138+}139+140+// SpellOut turns a slug into a title, which is all the naming some sources141+// give a thing.142+func SpellOut(slug string) string {143+ words := strings.Split(slug, "-")144+ for index, word := range words {145+ if word != "" {146+ words[index] = strings.ToUpper(word[:1]) + word[1:]147+ }148+ }149+ return strings.Join(words, " ")150+}
A internal/mgdoc/mgdoc_test.go +59 -0
@@ -0,0 +1,59 @@
1+package mgdoc2+3+import (4+ "hash/fnv"5+ "math"6+ "testing"7+)8+9+func TestClaimRefusesAnOccupiedID(t *testing.T) {10+ space := NewIDSpace()11+ hash := fnv.New32a()12+ _, _ = hash.Write([]byte("some-seed"))13+ occupied := int64(hash.Sum32() & 0x7fffffff)14+ space.seen[occupied] = "already-here"15+ if _, err := space.Claim("some-seed"); err == nil {16+ t.Fatal("claiming an occupied id did not fail")17+ }18+}19+20+func TestClaimStaysWithinPositiveInt31(t *testing.T) {21+ space := NewIDSpace()22+ for _, seed := range []string{"a", "b", "some:longer:seed", "ign:marker:x"} {23+ id, err := space.Claim(seed)24+ if err != nil {25+ t.Fatal(err)26+ }27+ if id <= 0 || id > math.MaxInt32 {28+ t.Fatalf("Claim(%q) = %d, outside the positive int31 range", seed, id)29+ }30+ }31+}32+33+// TestSyntheticCoordinatesInvertTheViewer projects the synthetic coordinates34+// with the viewer's own formula and expects the original pixel back.35+func TestSyntheticCoordinatesInvertTheViewer(t *testing.T) {36+ for _, pixel := range [][2]float64{{0, 0}, {4096, 4096}, {8192, 8192}, {123.5, 7000.25}} {37+ latitude := SyntheticLatitude(pixel[1])38+ longitude := SyntheticLongitude(pixel[0])39+ worldTiles := math.Pow(2, SourceZoom)40+ x := ((longitude + 180) / 360) * worldTiles * TileSize41+ y := (1 - math.Asinh(math.Tan(latitude*math.Pi/180))/math.Pi) / 2 * worldTiles * TileSize42+ if math.Abs(x-pixel[0]) > 1e-6 || math.Abs(y-pixel[1]) > 1e-6 {43+ t.Fatalf("pixel %v round-tripped to %.8f,%.8f", pixel, x, y)44+ }45+ }46+}47+48+func TestSpellOut(t *testing.T) {49+ cases := map[string]string{50+ "night-city": "Night City",51+ "ncpd-scanner-hustles": "Ncpd Scanner Hustles",52+ "collectibles": "Collectibles",53+ }54+ for slug, want := range cases {55+ if got := SpellOut(slug); got != want {56+ t.Errorf("SpellOut(%q) = %q, want %q", slug, got, want)57+ }58+ }59+}