clone

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

archive/golden-reference
atlas / internal / mgdoc / mgdoc.go
mgdoc.go 8.6K
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.8package mgdoc910import (11 "encoding/json"12 "fmt"13 "hash/fnv"14 "math"15 "strings"16)1718// The world every translated map lives in: a 32-tile square, 8192 pixels19// across, whose tile pyramid starts at a single tile. Pins are stored as20// latitude and longitude and projected by the viewer, so a marker's linear21// position on a source's image becomes the synthetic coordinate that projects22// back onto that exact pixel.23const (24 SourceZoom = 525 TileSize = 25626 WorldSize = 819227)2829// Only the fields tools/tiles and tools/generate actually read, spelled with30// the snake_case tags their own decoders name. Attrs carries the semantic31// conventions a translator declares -- registered atlas.* keys -- under a32// field no real MapGenie capture spells, so a snapshot passing through33// untranslated simply says nothing and the generator speaks for it.34type Map struct {35 ID int64 `json:"id"`36 Title string `json:"title"`37 Slug string `json:"slug"`38 InitialLatitude float64 `json:"initial_latitude"`39 InitialLongitude float64 `json:"initial_longitude"`40 Config Config `json:"config"`41 Game Game `json:"game"`42 Groups []Group `json:"groups"`43 Regions []Region `json:"regions"`44 Collections []CollectionDecl `json:"atlas_collections,omitempty"`45 Attrs map[string]string `json:"atlas_attrs,omitempty"`46}4748// CollectionDecl names a curated collection the map's regions may claim: a49// translator that knows its regions come in kinds -- the zoning districts, the50// streams -- declares each kind once and lets every region say which it is.51// Attrs carry the collection's conventions -- its geometry kind, its label52// policy, the stroke width its paths draw at -- under the same registered keys53// everything else speaks. The field rides a name no real MapGenie capture54// spells, so an untranslated snapshot simply declares nothing and its regions55// fold into the map's implicit region collection as they always have.56type CollectionDecl struct {57 Key string `json:"key"`58 Title string `json:"title"`59 Attrs map[string]string `json:"atlas_attrs,omitempty"`60}6162// Region is a named piece of ground: the generator reads it into a zone, and63// its features carry GeoJSON-shaped polygon geometry whose positions are the64// same synthetic longitude and latitude a pin speaks. Sources without ground65// to name leave the slice empty rather than absent, which is the shape a real66// MapGenie capture spells.67type Region struct {68 ID int64 `json:"id"`69 Title string `json:"title"`70 Subtitle string `json:"subtitle,omitempty"`71 ParentRegionID *int64 `json:"parent_region_id,omitempty"`72 // Description is the region's prose -- what a zoning district permits,73 // what a place is -- deferred by the generator into the text payload74 // the way a pin's description is, so naming ground stays cheap and75 // explaining it costs only the reader who asks.76 Description string `json:"description,omitempty"`77 CenterX *float64 `json:"center_x,omitempty"`78 CenterY *float64 `json:"center_y,omitempty"`79 Features []RegionFeature `json:"features,omitempty"`80 // Collection is the key of the declared collection this region belongs81 // to. A region naming nothing folds into the map's implicit region82 // collection, which is every region a real MapGenie capture holds.83 Collection string `json:"atlas_collection,omitempty"`84 Attrs map[string]string `json:"atlas_attrs,omitempty"`85}8687type RegionFeature struct {88 Geometry Geometry `json:"geometry"`89}9091// Geometry is the GeoJSON slice of a region feature: Polygon or MultiPolygon92// for ground, MultiLineString for a path -- a region that is really a line93// declares its width as an attribute rather than pretending to be an area --94// coordinates kept raw because the generator passes them through verbatim.95type Geometry struct {96 Type string `json:"type"`97 Coordinates json.RawMessage `json:"coordinates"`98}99100type Game struct {101 ID int64 `json:"id"`102 Title string `json:"title"`103 Slug string `json:"slug"`104}105106type Config struct {107 TileSets []TileSet `json:"tile_sets"`108}109110type TileSet struct {111 Name string `json:"name"`112 Path string `json:"path"`113 MinZoom int `json:"min_zoom"`114 MaxZoom int `json:"max_zoom"`115 Extension string `json:"extension"`116 Bounds map[string]Bound `json:"bounds"`117}118119type Bound struct {120 X Range `json:"x"`121 Y Range `json:"y"`122}123124type Range struct {125 Min int `json:"min"`126 Max int `json:"max"`127}128129type Group struct {130 ID int64 `json:"id"`131 Title string `json:"title"`132 Categories []Category `json:"categories"`133}134135type Category struct {136 ID int64 `json:"id"`137 Title string `json:"title"`138 Icon string `json:"icon"`139 DisplayType string `json:"display_type"`140 Visible bool `json:"visible"`141 Locations []Location `json:"locations"`142 Attrs map[string]string `json:"atlas_attrs,omitempty"`143}144145type Location struct {146 ID int64 `json:"id"`147 Title string `json:"title"`148 Description string `json:"description,omitempty"`149 Latitude float64 `json:"latitude"`150 Longitude float64 `json:"longitude"`151 Attrs map[string]string `json:"atlas_attrs,omitempty"`152}153154// Synthetic coordinates invert the viewer's projection, so a pin's linear155// position on a source's image, run through the same spherical Mercator every156// other map uses, lands on the same pixel it was captured at. The world here157// is the map's own window -- a 32-tile square starting at tile zero -- which158// the bundle carries as this map's grid.159func SyntheticLongitude(x float64) float64 {160 worldTiles := math.Pow(2, SourceZoom)161 xTile := x / TileSize162 return (xTile/worldTiles)*360 - 180163}164165func SyntheticLatitude(y float64) float64 {166 worldTiles := math.Pow(2, SourceZoom)167 yTile := y / TileSize168 return math.Atan(math.Sinh(math.Pi*(1-2*yTile/worldTiles))) * 180 / math.Pi169}170171// ProjectX and ProjectY are the viewer's projection, parameterised by the172// window a map is cut from: the inverse of the synthetic coordinates above173// for the shared window, and of MapGenie's own for every other. They exist174// here so anything comparing two sources' pins can land both in pixels175// without carrying its own copy of the formula.176func ProjectX(longitude float64, sourceZoom, firstTile int) float64 {177 worldTiles := math.Pow(2, float64(sourceZoom))178 xTile := ((longitude + 180) / 360) * worldTiles179 return (xTile - float64(firstTile)) * TileSize180}181182func ProjectY(latitude float64, sourceZoom, firstTile int) float64 {183 worldTiles := math.Pow(2, float64(sourceZoom))184 yTile := (1 - math.Asinh(math.Tan(latitude*math.Pi/180))/math.Pi) / 2 * worldTiles185 return (yTile - float64(firstTile)) * TileSize186}187188// IDSpace hands out the numeric identifiers the pipeline expects, derived189// from the stable names a source publishes, so the same capture always190// numbers itself the same way. Identifiers stay within a positive int31 --191// the packed location format and the viewer both read them as signed 32-bit192// -- and zero is avoided because the viewer reads zero as absence. A193// collision between two names is vanishingly unlikely at this scale and194// loudly fatal rather than silently merging two pins.195type IDSpace struct {196 seen map[int64]string197}198199func NewIDSpace() *IDSpace {200 return &IDSpace{seen: make(map[int64]string)}201}202203func (s *IDSpace) Claim(seed string) (int64, error) {204 hash := fnv.New32a()205 _, _ = hash.Write([]byte(seed))206 id := int64(hash.Sum32() & 0x7fffffff)207 if id == 0 {208 id = 1209 }210 if holder, taken := s.seen[id]; taken {211 return 0, fmt.Errorf("id collision: %q and %q both hash to %d", holder, seed, id)212 }213 s.seen[id] = seed214 return id, nil215}216217// SpellOut turns a slug into a title, which is all the naming some sources218// give a thing.219func SpellOut(slug string) string {220 words := strings.Split(slug, "-")221 for index, word := range words {222 if word != "" {223 words[index] = strings.ToUpper(word[:1]) + word[1:]224 }225 }226 return strings.Join(words, " ")227}