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 / pbmap / pbmap.go
pbmap.go 14.4K
1// Package pbmap reads a captured Piggyback map and speaks MapGenie for it.2//3// Piggyback is the official guide house, and its maps carry what IGN's do not:4// prose. Pins arrive with names and descriptions, and both survive into the5// bundle. Like ignmap, this package translates at read time -- the archive6// keeps the capture as Piggyback served it, and every keep-or-leave decision7// lives here where changing it re-applies to captures already on disk.8//9// Piggyback draws its world in game coordinates on a Leaflet CRS.Simple map:10// a linear transformation squeezes them onto the unit tile at zoom zero. The11// capture records that transformation, and pins pass through it, then through12// the shared inverse Mercator, to land on the same pixels Piggyback draws13// them at.14package pbmap1516import (17 "encoding/json"18 "fmt"19 "sort"20 "strconv"21 "strings"2223 "github.com/FelineStateMachine/atlas/internal/mgdoc"24 "github.com/FelineStateMachine/atlas/internal/semconv"25)2627// Kind names Piggyback captures in a map's snapshot index.28const Kind = "piggyback-map"2930// Source is the value a well-formed capture carries in its source field.31const Source = "piggyback"3233// Capture is the canonical document the crawler stores: the map's settings,34// the category tree, every pin, and the tile windows the crawler observed --35// Piggyback publishes no bounds, so the crawler's survey is the only account36// of where the pyramid is drawn. Fields marshal in declaration order and every37// list is sorted by Normalize, so unchanged data always hashes to the capture38// already archived.39type Capture struct {40 Source string `json:"source"`41 GameSlug string `json:"gameSlug"`42 MapSlug string `json:"mapSlug"`43 GameTitle string `json:"gameTitle"`44 MapTitle string `json:"mapTitle"`45 Map MapConfig `json:"map"`46 Labels Labels `json:"labels"`47 Categories []Category `json:"categories"`48 Pins []Pin `json:"pins"`49 Levels []Level `json:"levels"`50}5152// MapConfig keeps the settings the page ships and the transformation the53// map's own scripts project through. MaxZoom is the deepest level actually54// captured; the premium ceiling rides along as a matter of record.55type MapConfig struct {56 TileServer string `json:"tileServer"`57 MinZoom int `json:"minZoom"`58 MaxZoom int `json:"maxZoom"`59 PremiumMaxZoom int `json:"premiumMaxZoom,omitempty"`60 Transform Transform `json:"transform"`61}6263// Transform is Leaflet's affine pair: a point (x, y) in game coordinates64// draws at (a*x + b, c*y + d) on the unit tile at zoom zero.65type Transform struct {66 A float64 `json:"a"`67 B float64 `json:"b"`68 C float64 `json:"c"`69 D float64 `json:"d"`70}7172// Labels carries the display names the page's language bundle gives the73// category and type keys, which the data API never repeats.74type Labels struct {75 Categories []Label `json:"categories"`76 Types []Label `json:"types"`77}7879type Label struct {80 Key string `json:"key"`81 Label string `json:"label"`82}8384type Category struct {85 ID string `json:"id"`86 Key string `json:"key"`87 Position float64 `json:"position"`88 Types []Type `json:"types"`89}9091type Type struct {92 ID string `json:"id"`93 Key string `json:"key"`94 Position float64 `json:"position"`95}9697// Pin keeps a pin the way Piggyback serves it, coordinates included as the98// strings they arrive in.99type Pin struct {100 ID string `json:"id"`101 X string `json:"x"`102 Y string `json:"y"`103 CategoryKey string `json:"categoryKey"`104 TypeKey string `json:"typeKey"`105 Name string `json:"name"`106 Description string `json:"description,omitempty"`107}108109// Level is one observed tile window: at Zoom, tiles exist from MinX,MinY110// through MaxX,MaxY inclusive.111type Level struct {112 Zoom int `json:"zoom"`113 MinX int `json:"minX"`114 MinY int `json:"minY"`115 MaxX int `json:"maxX"`116 MaxY int `json:"maxY"`117}118119// Normalize puts the capture into its canonical order.120func (c *Capture) Normalize() {121 sort.Slice(c.Labels.Categories, func(a, b int) bool {122 return c.Labels.Categories[a].Key < c.Labels.Categories[b].Key123 })124 sort.Slice(c.Labels.Types, func(a, b int) bool {125 return c.Labels.Types[a].Key < c.Labels.Types[b].Key126 })127 sort.SliceStable(c.Categories, func(a, b int) bool {128 if c.Categories[a].Position != c.Categories[b].Position {129 return c.Categories[a].Position < c.Categories[b].Position130 }131 return c.Categories[a].Key < c.Categories[b].Key132 })133 for index := range c.Categories {134 types := c.Categories[index].Types135 sort.SliceStable(types, func(a, b int) bool {136 if types[a].Position != types[b].Position {137 return types[a].Position < types[b].Position138 }139 return types[a].Key < types[b].Key140 })141 }142 sort.Slice(c.Pins, func(a, b int) bool { return c.Pins[a].ID < c.Pins[b].ID })143 sort.Slice(c.Levels, func(a, b int) bool { return c.Levels[a].Zoom < c.Levels[b].Zoom })144}145146// districtTypes are Piggyback's region name markers. They arrive filed under147// the user-state "favorites" category, which is nothing to build a legend148// from; they become floating text labels, which is what they are on149// Piggyback's own map.150var districtTypes = map[string]bool{151 "province": true,152 "sub-region": true,153}154155// MaybeTranslate hands other sources' snapshots through untouched and156// translates Piggyback captures.157func MaybeTranslate(kind string, doc []byte) ([]byte, error) {158 if kind != Kind {159 return doc, nil160 }161 return Translate(doc)162}163164// Translate turns a capture into the MapGenie-shaped document the pipeline165// reads, deterministically for a given capture.166func Translate(doc []byte) ([]byte, error) {167 var capture Capture168 if err := json.Unmarshal(doc, &capture); err != nil {169 return nil, fmt.Errorf("decode piggyback capture: %w", err)170 }171 if capture.Source != Source {172 return nil, fmt.Errorf("capture source is %q, not %q", capture.Source, Source)173 }174 if capture.GameSlug == "" || capture.MapSlug == "" {175 return nil, fmt.Errorf("capture names no map")176 }177 if len(capture.Levels) == 0 {178 return nil, fmt.Errorf("capture observed no tile levels")179 }180 if capture.Map.Transform.A == 0 || capture.Map.Transform.C == 0 {181 return nil, fmt.Errorf("capture carries no usable transformation")182 }183 capture.Normalize()184185 ids := mgdoc.NewIDSpace()186 scope := capture.GameSlug + "/" + capture.MapSlug187 mapID, err := ids.Claim("pb:map:" + scope)188 if err != nil {189 return nil, err190 }191 gameID, err := ids.Claim("pb:game:" + capture.GameSlug)192 if err != nil {193 return nil, err194 }195196 set, err := tileSet(&capture)197 if err != nil {198 return nil, err199 }200 groups, err := buildGroups(&capture, ids, scope)201 if err != nil {202 return nil, err203 }204205 centerX, centerY := deepestWindowCenter(&capture)206 out := mgdoc.Map{207 ID: mapID,208 Title: title(capture.MapTitle, capture.MapSlug),209 Slug: capture.MapSlug,210 InitialLatitude: mgdoc.SyntheticLatitude(centerY),211 InitialLongitude: mgdoc.SyntheticLongitude(centerX),212 Config: mgdoc.Config{TileSets: []mgdoc.TileSet{set}},213 Game: mgdoc.Game{214 ID: gameID,215 // The plain slug, deliberately shared with every other source's216 // capture of the same game: their bundles answer for one library217 // entry, and the newest capture is the one the reader sees.218 Title: title(capture.GameTitle, capture.GameSlug),219 Slug: capture.GameSlug,220 },221 Groups: groups,222 Regions: []mgdoc.Region{},223 }224 return json.Marshal(out)225}226227func title(given, slug string) string {228 if given != "" {229 return given230 }231 return mgdoc.SpellOut(slug)232}233234// TileSetPath cuts the layer path out of the tile URL template: everything235// between "/tiles/" and the zoom placeholder, which is how tile records group236// into layers downstream.237func TileSetPath(template string) (string, error) {238 _, rest, found := strings.Cut(template, "/tiles/")239 if !found {240 return "", fmt.Errorf("tile template %q has no /tiles/ segment", template)241 }242 path, _, found := strings.Cut(rest, "/{z}")243 if !found || path == "" {244 return "", fmt.Errorf("tile template %q does not name a layer before its zoom", template)245 }246 return path, nil247}248249// TileExtension reads the image format off the tile URL template.250func TileExtension(template string) string {251 if dot := strings.LastIndex(template, "."); dot >= 0 && dot < len(template)-1 {252 return strings.ToLower(template[dot+1:])253 }254 return "webp"255}256257// tileSet declares the pyramid from the crawler's survey. Bounds are declared258// for every observed level: a level without them would be measured against259// the MapGenie window this map does not sit in.260func tileSet(capture *Capture) (mgdoc.TileSet, error) {261 path, err := TileSetPath(capture.Map.TileServer)262 if err != nil {263 return mgdoc.TileSet{}, err264 }265 deepest := capture.Levels[len(capture.Levels)-1]266 set := mgdoc.TileSet{267 Name: "Default",268 Path: path,269 MinZoom: capture.Levels[0].Zoom,270 MaxZoom: deepest.Zoom,271 Extension: TileExtension(capture.Map.TileServer),272 Bounds: make(map[string]mgdoc.Bound, len(capture.Levels)),273 }274 for _, level := range capture.Levels {275 set.Bounds[strconv.Itoa(level.Zoom)] = mgdoc.Bound{276 X: mgdoc.Range{Min: level.MinX, Max: level.MaxX},277 Y: mgdoc.Range{Min: level.MinY, Max: level.MaxY},278 }279 }280 return set, nil281}282283// worldPixel runs a pin's game coordinates through the map's transformation284// onto the zoom-zero tile, then scales to the shared 8192-pixel world.285func worldPixel(transform Transform, x, y float64) (float64, float64) {286 const zoomZeroSpan = 256287 scale := float64(mgdoc.WorldSize) / zoomZeroSpan288 return (transform.A*x + transform.B) * scale, (transform.C*y + transform.D) * scale289}290291func deepestWindowCenter(capture *Capture) (float64, float64) {292 deepest := capture.Levels[len(capture.Levels)-1]293 span := float64(mgdoc.WorldSize) / float64(int(1)<<deepest.Zoom)294 x := (float64(deepest.MinX) + float64(deepest.MaxX+1)) / 2 * span295 y := (float64(deepest.MinY) + float64(deepest.MaxY+1)) / 2 * span296 return x, y297}298299// buildGroups turns Piggyback's category tree into the pipeline's groups. A300// declared category becomes a group, its types become categories, and a type301// no pin uses is left out. District name pins -- filed under the user-state302// favorites category -- gather instead into their own group as floating text303// labels. Anything else a pin references without a declaration is an error:304// better a loud crawl than a pin silently dropped.305func buildGroups(capture *Capture, ids *mgdoc.IDSpace, scope string) ([]mgdoc.Group, error) {306 categoryLabels := labelIndex(capture.Labels.Categories)307 typeLabels := labelIndex(capture.Labels.Types)308309 pinsByType := make(map[string][]Pin)310 for _, pin := range capture.Pins {311 pinsByType[pin.TypeKey] = append(pinsByType[pin.TypeKey], pin)312 }313314 declared := make(map[string]bool)315 var groups []mgdoc.Group316 for _, category := range capture.Categories {317 if category.Key == "favorites" {318 // The reader's own favorites, not the map's data. Its stowaway319 // district pins are picked up below.320 continue321 }322 var categories []mgdoc.Category323 for _, t := range category.Types {324 declared[t.Key] = true325 built, ok, err := buildCategory(capture, ids, scope, category.Key, t.Key,326 label(typeLabels, t.Key), "markers", pinsByType)327 if err != nil {328 return nil, err329 }330 if ok {331 categories = append(categories, built)332 }333 }334 if len(categories) == 0 {335 continue336 }337 groupID, err := ids.Claim("pb:category:" + scope + ":" + category.Key)338 if err != nil {339 return nil, err340 }341 groups = append(groups, mgdoc.Group{342 ID: groupID,343 Title: label(categoryLabels, category.Key),344 Categories: categories,345 })346 }347348 // District names, and a loud failure for anything else undeclared.349 var districts []mgdoc.Category350 for _, key := range sortedKeys(pinsByType) {351 if declared[key] {352 continue353 }354 if !districtTypes[key] {355 return nil, fmt.Errorf("pins use type %q, which no category declares", key)356 }357 built, ok, err := buildCategory(capture, ids, scope, "districts", key,358 label(typeLabels, key), "text", pinsByType)359 if err != nil {360 return nil, err361 }362 if ok {363 districts = append(districts, built)364 }365 }366 if len(districts) > 0 {367 groupID, err := ids.Claim("pb:category:" + scope + ":districts")368 if err != nil {369 return nil, err370 }371 groups = append(groups, mgdoc.Group{ID: groupID, Title: "Districts", Categories: districts})372 }373 return groups, nil374}375376func buildCategory(377 capture *Capture,378 ids *mgdoc.IDSpace,379 scope, categoryKey, typeKey, typeLabel, displayType string,380 pinsByType map[string][]Pin,381) (mgdoc.Category, bool, error) {382 pins := pinsByType[typeKey]383 if len(pins) == 0 {384 return mgdoc.Category{}, false, nil385 }386 categoryID, err := ids.Claim("pb:type:" + scope + ":" + categoryKey + ":" + typeKey)387 if err != nil {388 return mgdoc.Category{}, false, err389 }390 renderAs := semconv.RenderAsPin391 if displayType == "text" {392 renderAs = semconv.RenderAsText393 }394 category := mgdoc.Category{395 ID: categoryID,396 Title: typeLabel,397 // The type key, so icons dropped into the archive later attach on the398 // next generation without a policy change; until then the viewer's399 // fallback glyph stands in.400 Icon: typeKey,401 DisplayType: displayType,402 Visible: true,403 Locations: make([]mgdoc.Location, 0, len(pins)),404 Attrs: map[string]string{semconv.KeyRenderAs: renderAs},405 }406 for _, pin := range pins {407 locationID, err := ids.Claim("pb:pin:" + pin.ID)408 if err != nil {409 return mgdoc.Category{}, false, err410 }411 x, err := strconv.ParseFloat(pin.X, 64)412 if err != nil {413 return mgdoc.Category{}, false, fmt.Errorf("pin %s x %q: %w", pin.ID, pin.X, err)414 }415 y, err := strconv.ParseFloat(pin.Y, 64)416 if err != nil {417 return mgdoc.Category{}, false, fmt.Errorf("pin %s y %q: %w", pin.ID, pin.Y, err)418 }419 pixelX, pixelY := worldPixel(capture.Map.Transform, x, y)420 name := pin.Name421 if name == "" {422 name = typeLabel423 }424 category.Locations = append(category.Locations, mgdoc.Location{425 ID: locationID,426 Title: name,427 Description: pin.Description,428 Latitude: mgdoc.SyntheticLatitude(pixelY),429 Longitude: mgdoc.SyntheticLongitude(pixelX),430 })431 }432 return category, true, nil433}434435func labelIndex(labels []Label) map[string]string {436 out := make(map[string]string, len(labels))437 for _, l := range labels {438 out[l.Key] = l.Label439 }440 return out441}442443func label(index map[string]string, key string) string {444 if l, ok := index[key]; ok && l != "" {445 return l446 }447 return mgdoc.SpellOut(key)448}449450func sortedKeys(pins map[string][]Pin) []string {451 keys := make([]string, 0, len(pins))452 for key := range pins {453 keys = append(keys, key)454 }455 sort.Strings(keys)456 return keys457}