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 / ignmap / ignmap.go
ignmap.go 10.8K
1// Package ignmap reads a captured IGN wikimap and speaks MapGenie for it.2//3// The crawler stores what IGN publishes -- a map's configuration, its marker4// types, and every marker -- as one canonical document, and nothing downstream5// ever sees that document directly. Translate turns it into the MapGenie-shaped6// JSON that tools/tiles and tools/generate already decode, so a second source7// flows through the pipeline without the pipeline learning a second schema.8//9// Translation happens when a capture is read, not when it is taken. The archive10// keeps IGN's data as IGN published it, and every decision about what a bundle11// keeps or leaves -- dropping empty categories, holding back wiki links the12// offline app cannot follow -- lives here, where changing it re-applies to13// every capture already on disk.14package ignmap1516import (17 "encoding/json"18 "fmt"19 "math"20 "sort"21 "strconv"22 "strings"2324 "github.com/FelineStateMachine/atlas/internal/mgdoc"25 "github.com/FelineStateMachine/atlas/internal/semconv"26)2728// Kind names IGN captures in a map's snapshot index, alongside the "map"29// records MapGenie crawls write.30const Kind = "ign-map"3132// Source is the value a well-formed capture carries in its source field.33const Source = "ign-wikimaps"3435// Capture is the canonical document the crawler stores. Its fields are structs36// and slices only, marshaled in declaration order, so the same data always37// produces the same bytes and an unchanged re-crawl hashes to the capture38// already archived. Anything volatile on the page -- build ids, trace headers,39// timestamps -- stays out; when the capture was taken is the snapshot index's40// business.41type Capture struct {42 Source string `json:"source"`43 ObjectSlug string `json:"objectSlug"`44 MapSlug string `json:"mapSlug"`45 GameTitle string `json:"gameTitle"`46 MapTitle string `json:"mapTitle"`47 Map MapConfig `json:"map"`48 Types []Type `json:"types"`49 Markers []Marker `json:"markers"`50}5152// MapConfig is the slice of IGN's page config a bundle needs. Width and height53// are normalized to the map image: the taller dimension spans 1, so a marker's54// coordinates and the tile pyramid describe the same square world.55type MapConfig struct {56 Width float64 `json:"width"`57 Height float64 `json:"height"`58 MinZoom int `json:"minZoom"`59 MaxZoom int `json:"maxZoom"`60 InitialLat float64 `json:"initialLat"`61 InitialLng float64 `json:"initialLng"`62 BackgroundColor string `json:"backgroundColor"`63 Tileset string `json:"tileset"`64}6566type Type struct {67 TypeSlug string `json:"typeSlug"`68 TypeName string `json:"typeName"`69 ParentTypeSlug string `json:"parentTypeSlug,omitempty"`70 IconURL string `json:"iconUrl,omitempty"`71 IconWidth int `json:"iconWidth,omitempty"`72 IconHeight int `json:"iconHeight,omitempty"`73}7475// Marker keeps every field IGN serves for a pin, including the ones a bundle76// holds back today. WikiPage names an article on IGN's wiki -- the offline app77// cannot follow it, so it stays archived here until something can be made of78// it; the raw messages pass through whatever shape IGN used, which their79// endpoint has not settled.80type Marker struct {81 ID string `json:"id"`82 Lat float64 `json:"lat"`83 Lng float64 `json:"lng"`84 MarkerName string `json:"markerName"`85 MarkerSlug string `json:"markerSlug"`86 TypeSlug string `json:"typeSlug"`87 WikiPage string `json:"wikiPage,omitempty"`88 IconSlug json.RawMessage `json:"iconSlug,omitempty"`89 RegionID json.RawMessage `json:"regionId,omitempty"`90 ChecklistTaskID json.RawMessage `json:"checklistTaskId,omitempty"`91}9293// Normalize puts the capture into its canonical order. The crawler calls this94// before marshaling so that IGN returning the same markers in a different95// order does not masquerade as a new version.96func (c *Capture) Normalize() {97 sort.Slice(c.Types, func(a, b int) bool { return c.Types[a].TypeSlug < c.Types[b].TypeSlug })98 sort.Slice(c.Markers, func(a, b int) bool { return c.Markers[a].ID < c.Markers[b].ID })99}100101// MaybeTranslate hands other sources' snapshots through untouched and102// translates IGN captures, so the tools that read snapshots stay one call103// away from either.104func MaybeTranslate(kind string, doc []byte) ([]byte, error) {105 if kind != Kind {106 return doc, nil107 }108 return Translate(doc)109}110111// Translate turns a capture into the MapGenie-shaped document the pipeline112// reads. The output is deterministic for a given capture, so re-generating an113// unchanged archive reproduces byte-identical payloads and the bundle stamp114// stands still.115func Translate(doc []byte) ([]byte, error) {116 var capture Capture117 if err := json.Unmarshal(doc, &capture); err != nil {118 return nil, fmt.Errorf("decode ign capture: %w", err)119 }120 if capture.Source != Source {121 return nil, fmt.Errorf("capture source is %q, not %q", capture.Source, Source)122 }123 if capture.ObjectSlug == "" || capture.MapSlug == "" {124 return nil, fmt.Errorf("capture names no map")125 }126 if capture.Map.Width <= 0 || capture.Map.Height <= 0 {127 return nil, fmt.Errorf("capture map has no size")128 }129 capture.Normalize()130131 ids := mgdoc.NewIDSpace()132 scope := capture.ObjectSlug + "/" + capture.MapSlug133 mapID, err := ids.Claim("ign:map:" + scope)134 if err != nil {135 return nil, err136 }137 gameID, err := ids.Claim("ign:game:" + capture.ObjectSlug)138 if err != nil {139 return nil, err140 }141142 groups, err := buildGroups(&capture, ids, scope)143 if err != nil {144 return nil, err145 }146147 out := mgdoc.Map{148 ID: mapID,149 Title: mapTitle(&capture),150 Slug: capture.MapSlug,151 InitialLatitude: mgdoc.SyntheticLatitude(-capture.Map.InitialLat * mgdoc.WorldSize),152 InitialLongitude: mgdoc.SyntheticLongitude(capture.Map.InitialLng * mgdoc.WorldSize),153 Config: mgdoc.Config{TileSets: []mgdoc.TileSet{tileSet(&capture, scope)}},154 Game: mgdoc.Game{155 ID: gameID,156 // The game keeps its plain slug, so captures of the same game from157 // different sources build bundles that answer for one library entry158 // and the newest capture is the one the reader sees.159 Title: capture.GameTitle,160 Slug: capture.ObjectSlug,161 },162 Groups: groups,163 Regions: []mgdoc.Region{},164 }165 return json.Marshal(out)166}167168// mapTitle prefers the name IGN gives the map and falls back to its slug,169// spelled out, when the page offers none.170func mapTitle(capture *Capture) string {171 if capture.MapTitle != "" {172 return capture.MapTitle173 }174 return mgdoc.SpellOut(capture.MapSlug)175}176177// tileSet describes the pyramid the crawler captured in the shape tools/tiles178// expects. Bounds are declared for every level: a level without them would be179// measured against the MapGenie window this map does not sit in.180func tileSet(capture *Capture, scope string) mgdoc.TileSet {181 set := mgdoc.TileSet{182 // The name rides into the legend's layer picker -- and, when this183 // raster is resampled into a finer source's map, it names the184 // variant there too, so it says where the picture came from.185 Name: "IGN Wiki",186 Path: scope,187 MinZoom: 0,188 MaxZoom: capture.Map.MaxZoom,189 Extension: TileExtension(capture.Map.Tileset),190 Bounds: make(map[string]mgdoc.Bound, capture.Map.MaxZoom+1),191 }192 for zoom := 0; zoom <= capture.Map.MaxZoom; zoom++ {193 maxX, maxY := LevelExtent(capture.Map.Width, capture.Map.Height, zoom)194 set.Bounds[strconv.Itoa(zoom)] = mgdoc.Bound{195 X: mgdoc.Range{Min: 0, Max: maxX},196 Y: mgdoc.Range{Min: 0, Max: maxY},197 }198 }199 return set200}201202// LevelExtent reports the last tile column and row holding any of the image at203// a zoom. The crawler asks for exactly these tiles, and Translate declares204// exactly these bounds, because they are the same call.205func LevelExtent(width, height float64, zoom int) (maxX, maxY int) {206 across := float64(int(1) << zoom)207 return lastTile(width, across), lastTile(height, across)208}209210func lastTile(extent, across float64) int {211 last := int(math.Ceil(extent*across)) - 1212 if last < 0 {213 return 0214 }215 return last216}217218// TileExtension reads the image format off IGN's tile URL template.219func TileExtension(template string) string {220 if dot := strings.LastIndex(template, "."); dot >= 0 && dot < len(template)-1 {221 return strings.ToLower(template[dot+1:])222 }223 return "jpg"224}225226// buildGroups arranges IGN's flat marker types the way the pipeline expects227// them: categories inside groups. Types naming a parent gather under it, the228// rest share one group. A type no marker uses is left out -- an empty category229// would only dim the legend -- but it stays in the capture, so a marker230// appearing under it later revives the category without a policy change.231func buildGroups(capture *Capture, ids *mgdoc.IDSpace, scope string) ([]mgdoc.Group, error) {232 markersByType := make(map[string][]Marker, len(capture.Types))233 known := make(map[string]bool, len(capture.Types))234 for _, t := range capture.Types {235 if known[t.TypeSlug] {236 return nil, fmt.Errorf("type %q appears twice", t.TypeSlug)237 }238 known[t.TypeSlug] = true239 }240 for _, marker := range capture.Markers {241 if !known[marker.TypeSlug] {242 return nil, fmt.Errorf("marker %s uses undeclared type %q", marker.ID, marker.TypeSlug)243 }244 markersByType[marker.TypeSlug] = append(markersByType[marker.TypeSlug], marker)245 }246247 grouped := make(map[string][]mgdoc.Category)248 var order []string249 for _, t := range capture.Types {250 markers := markersByType[t.TypeSlug]251 if len(markers) == 0 {252 continue253 }254 categoryID, err := ids.Claim("ign:type:" + scope + ":" + t.TypeSlug)255 if err != nil {256 return nil, err257 }258 category := mgdoc.Category{259 ID: categoryID,260 Title: t.TypeName,261 Icon: t.TypeSlug,262 DisplayType: "markers",263 Visible: true,264 Locations: make([]mgdoc.Location, 0, len(markers)),265 Attrs: map[string]string{semconv.KeyRenderAs: semconv.RenderAsPin},266 }267 for _, marker := range markers {268 locationID, err := ids.Claim("ign:marker:" + marker.ID)269 if err != nil {270 return nil, err271 }272 category.Locations = append(category.Locations, mgdoc.Location{273 ID: locationID,274 Title: marker.MarkerName,275 Latitude: mgdoc.SyntheticLatitude(-marker.Lat * mgdoc.WorldSize),276 Longitude: mgdoc.SyntheticLongitude(marker.Lng * mgdoc.WorldSize),277 })278 }279 key := t.ParentTypeSlug280 if _, seen := grouped[key]; !seen {281 order = append(order, key)282 }283 grouped[key] = append(grouped[key], category)284 }285286 groups := make([]mgdoc.Group, 0, len(order))287 for _, key := range order {288 title := "Markers"289 seed := "ign:group:" + scope290 if key != "" {291 title = mgdoc.SpellOut(key)292 seed += ":" + key293 }294 groupID, err := ids.Claim(seed)295 if err != nil {296 return nil, err297 }298 groups = append(groups, mgdoc.Group{ID: groupID, Title: title, Categories: grouped[key]})299 }300 return groups, nil301}