clone

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

main
atlas / internal / generate / sources / piggyback / piggyback.go
piggyback.go 15.9K
1// Package piggyback reads a captured Piggyback map and translates it into the2// Atlas interchange document.3//4// Piggyback is the official guide house, and its maps carry what a community5// wikimap does not: prose. Pins arrive with names and descriptions, and both6// survive into a volume.7//8// # The coordinate design, and the gate on it9//10// Piggyback draws its world in a game's own coordinates on a Leaflet11// CRS.Simple map: a linear transformation squeezes them onto the unit tile at12// zoom zero. The capture records that transformation, and a pin passes through13// it and then through the shared inverse Mercator to land on the pixel Piggyback14// draws it at.15//16// That transformation is the one thing here that cannot be checked from the17// capture alone. It is read off the page's own scripts, it decides where every18// pin in the volume stands, and a wrong one puts a whole map's contents19// somewhere plausible and wrong -- which is exactly the failure a merge would20// then try to fit an affine to. So this reader refuses a transformation nobody21// has verified: verifiedTransforms is the list of games whose numbers have been22// checked against the published map, and a capture whose numbers are not in it23// fails the build rather than quietly misplacing a city.24//25// # What else is refused26//27// A capture of another source's kind; a capture naming no map; a capture whose28// crawler observed no tile level, since Piggyback publishes no bounds and the29// survey is the only account of the pyramid there is; a pin of a type no30// category declares, because the alternative is a pin silently dropped.31//32// # Determinism33//34// Categories and their types sort by declared position and then by key, pins by35// id, levels by zoom. The crawler does it before archiving and this reader does36// it again, because an archive holds captures older than the habit.37package piggyback3839import (40 "encoding/json"41 "errors"42 "fmt"43 "log/slog"44 "sort"45 "strconv"46 "strings"4748 "github.com/FelineStateMachine/atlas/format/semconv"49 "github.com/FelineStateMachine/atlas/internal/generate/archive"50 "github.com/FelineStateMachine/atlas/internal/generate/doc"51 "github.com/FelineStateMachine/atlas/internal/logging"52)5354// captureKind is what the archive calls a Piggyback capture, and source is what55// a well-formed capture says it is.56const (57 captureKind = "piggyback-map"58 source = "piggyback"59)6061// verifiedTransforms is the gate. A game appears here once its transformation62// has been checked against the published map -- a handful of known landmarks63// picked off both and compared -- and a capture carrying different numbers is64// refused rather than translated into a volume whose every pin is subtly65// somewhere else.66//67// Cyberpunk's pair maps the game's ±8192-unit world onto the 256-unit tile at68// zoom zero: 0.015625 is 4/256, and the offsets put the origin at the middle.69var verifiedTransforms = map[string]Transform{70 "cyberpunk-2077": {A: 0.015625, B: 128, C: -0.015625, D: 128},71}7273// districtTypes are Piggyback's region name markers. They arrive filed under the74// reader-state "favorites" category, which is nothing to build a legend from, so75// they become floating text labels -- which is what they are on Piggyback's own76// map.77var districtTypes = map[string]bool{78 "province": true,79 "sub-region": true,80}8182// artworkExtensions are the forms the archive holds a type's icon in.83var artworkExtensions = []string{".svg", ".png"}8485// Source reads Piggyback captures.86type Source struct{}8788// New builds the source. It holds no state.89func New() Source { return Source{} }9091// Describe is the source's account of itself.92func (Source) Describe() doc.Provenance {93 return doc.Provenance{94 Name: "piggyback",95 Label: "Piggyback",96 License: "All rights reserved. Piggyback's maps are the cartography of a " +97 "published strategy guide; a volume carrying them is for personal use.",98 Attribution: "Map imagery and annotations by Piggyback Interactive, " +99 "captured from maps.piggyback.com.",100 // Piggyback numbers pins, categories and types with opaque string ids,101 // none of which a bundle's wire can carry, so every identity is minted102 // from a stable name.103 IDSpace: doc.IDSpaceDerived,104 }105}106107// Translate reads one archived volume.108func (s Source) Translate(a *archive.Archive, v archive.VolumeRef, log *slog.Logger) (doc.Document, error) {109 log = log.With(logging.Source(source))110 worlds, err := a.Worlds(v)111 if err != nil {112 return doc.Document{}, err113 }114 out := doc.Document{115 Doc: doc.Doc,116 Version: doc.Version,117 Source: s.Describe(),118 }119 for _, ref := range worlds {120 world, volume, err := s.translateWorld(a, ref, log)121 if err != nil {122 if errors.Is(err, archive.ErrNotReady) {123 log.Debug("world skipped", logging.Path(archive.TrimRoot(a.Root(), ref.Dir())),124 "reason", err.Error())125 continue126 }127 return doc.Document{}, err128 }129 if out.Volume.Slug == "" {130 // The plain slug, deliberately shared with every other source's131 // capture of the same game: their volumes answer for one library132 // entry, and the newest capture is the one a reader sees.133 out.Volume = volume134 }135 out.Worlds = append(out.Worlds, world)136 }137 if len(out.Worlds) == 0 {138 return doc.Document{}, fmt.Errorf("%w: volume %s has no readable world", archive.ErrNotReady, v.Title)139 }140 if err := s.attachArtwork(a, v, &out); err != nil {141 return doc.Document{}, err142 }143 log.Info("volume translated", logging.Volume(out.Volume.Slug),144 "worlds", len(out.Worlds), "icons", len(out.Icons))145 return out, nil146}147148func (s Source) translateWorld(a *archive.Archive, ref archive.WorldRef, log *slog.Logger) (doc.World, doc.Volume, error) {149 archived, err := a.Newest(ref)150 if err != nil {151 return doc.World{}, doc.Volume{}, err152 }153 if archived.Kind != captureKind {154 return doc.World{}, doc.Volume{}, fmt.Errorf(155 "capture %s is of kind %q; the Piggyback reader answers only for %q",156 archived.ContentHash, archived.Kind, captureKind)157 }158 body, err := a.Body(ref, archived)159 if err != nil {160 return doc.World{}, doc.Volume{}, err161 }162 var raw capture163 if err := json.Unmarshal(body, &raw); err != nil {164 return doc.World{}, doc.Volume{}, fmt.Errorf("decode capture %s: %w", archived.ContentHash, err)165 }166 if err := check(&raw); err != nil {167 return doc.World{}, doc.Volume{}, fmt.Errorf("capture %s: %w", archived.ContentHash, err)168 }169 raw.normalize()170171 ids := doc.NewIDSpace()172 scope := raw.GameSlug + "/" + raw.MapSlug173 worldID, err := ids.Claim("pb:map:" + scope)174 if err != nil {175 return doc.World{}, doc.Volume{}, err176 }177 tileSet, err := TileSetPath(raw.Map.TileServer)178 if err != nil {179 return doc.World{}, doc.Volume{}, err180 }181 collections, err := collectionsOf(&raw, ids, scope)182 if err != nil {183 return doc.World{}, doc.Volume{}, fmt.Errorf("map %s: %w", scope, err)184 }185186 centerX, centerY := deepestWindowCenter(&raw)187 world := doc.World{188 ID: worldID,189 Slug: raw.MapSlug,190 Title: named(raw.MapTitle, raw.MapSlug),191 Center: doc.SyntheticPosition(centerX, centerY),192 Capture: doc.Capture{193 Kind: archived.Kind,194 ID: archived.SourceID,195 Locator: archived.SourceURL,196 ContentHash: archived.ContentHash,197 CapturedAt: archived.CapturedAt,198 },199 Lenses: []doc.Lens{{200 Name: "Default",201 TileSet: tileSet,202 Frame: frameOf(&raw),203 }},204 Collections: collections,205 }206 log.Debug("world translated", logging.World(raw.MapSlug),207 "collections", len(collections), "capture", archived.ContentHash)208 return world, doc.Volume{209 Slug: raw.GameSlug,210 Title: named(raw.GameTitle, raw.GameSlug),211 }, nil212}213214// check states what a capture has to be before anything is read out of it, and215// carries the verified-transform gate.216func check(raw *capture) error {217 if raw.Source != source {218 return fmt.Errorf("capture says its source is %q, not %q", raw.Source, source)219 }220 if raw.GameSlug == "" || raw.MapSlug == "" {221 return fmt.Errorf("capture names no map")222 }223 if len(raw.Levels) == 0 {224 return fmt.Errorf("capture observed no tile level, so nothing knows where the pyramid is drawn")225 }226 verified, known := verifiedTransforms[raw.GameSlug]227 if !known {228 return fmt.Errorf(229 "no transformation has been verified for %s; a Piggyback map is placed by numbers read "+230 "off its own page, and an unchecked pair would misplace every pin in the volume",231 raw.GameSlug)232 }233 if raw.Map.Transform != verified {234 return fmt.Errorf(235 "%s carries transformation %+v, which is not the verified %+v",236 raw.GameSlug, raw.Map.Transform, verified)237 }238 return nil239}240241// collectionsOf turns Piggyback's category tree into the legend order a document242// carries: a declared category becomes a heading, its types become collections243// under it, and a type no pin uses is left out. District name pins -- filed under244// the reader-state favorites category -- gather instead under their own heading245// as floating text labels. Anything else a pin references without a declaration246// is an error: better a loud build than a pin silently dropped.247func collectionsOf(raw *capture, ids *doc.IDSpace, scope string) ([]doc.Collection, error) {248 categoryLabels := index(raw.Labels.Categories)249 typeLabels := index(raw.Labels.Types)250251 byType := make(map[string][]pin)252 for _, p := range raw.Pins {253 byType[p.TypeKey] = append(byType[p.TypeKey], p)254 }255256 declared := make(map[string]bool)257 var out []doc.Collection258 for _, group := range raw.Categories {259 if group.Key == "favorites" {260 // A reader's own favourites, not the map's data. Its stowaway261 // district pins are picked up below.262 continue263 }264 var built []doc.Collection265 for _, t := range group.Types {266 declared[t.Key] = true267 collection, ok, err := collectionOf(raw, ids, scope, group.Key, t.Key,268 spelled(typeLabels, t.Key), semconv.RenderAsPin, byType)269 if err != nil {270 return nil, err271 }272 if ok {273 built = append(built, collection)274 }275 }276 if len(built) == 0 {277 continue278 }279 heading := spelled(categoryLabels, group.Key)280 for _, collection := range built {281 collection.Group = heading282 out = append(out, collection)283 }284 }285286 // District names, and a loud failure for anything else undeclared.287 var districts []doc.Collection288 for _, key := range sortedKeys(byType) {289 if declared[key] {290 continue291 }292 if !districtTypes[key] {293 return nil, fmt.Errorf("pins are of type %q, which no category declares", key)294 }295 collection, ok, err := collectionOf(raw, ids, scope, "districts", key,296 spelled(typeLabels, key), semconv.RenderAsText, byType)297 if err != nil {298 return nil, err299 }300 if ok {301 districts = append(districts, collection)302 }303 }304 for _, collection := range districts {305 collection.Group = "Districts"306 out = append(out, collection)307 }308 return out, nil309}310311func collectionOf(312 raw *capture,313 ids *doc.IDSpace,314 scope, categoryKey, typeKey, typeLabel, renderAs string,315 byType map[string][]pin,316) (doc.Collection, bool, error) {317 pins := byType[typeKey]318 if len(pins) == 0 {319 return doc.Collection{}, false, nil320 }321 id, err := ids.Claim("pb:type:" + scope + ":" + categoryKey + ":" + typeKey)322 if err != nil {323 return doc.Collection{}, false, err324 }325 collection := doc.Collection{326 ID: id,327 Title: typeLabel,328 Kind: doc.KindPoint,329 // The type key, so artwork dropped into the archive later attaches on330 // the next build without a policy change.331 Icon: typeKey,332 Visible: true,333 Attrs: map[string]string{semconv.KeyRenderAs: renderAs},334 Features: make([]doc.Feature, 0, len(pins)),335 }336 for _, p := range pins {337 featureID, err := ids.Claim("pb:pin:" + p.ID)338 if err != nil {339 return doc.Collection{}, false, err340 }341 x, err := strconv.ParseFloat(p.X, 64)342 if err != nil {343 return doc.Collection{}, false, fmt.Errorf("pin %s x %q: %w", p.ID, p.X, err)344 }345 y, err := strconv.ParseFloat(p.Y, 64)346 if err != nil {347 return doc.Collection{}, false, fmt.Errorf("pin %s y %q: %w", p.ID, p.Y, err)348 }349 pixelX, pixelY := worldPixel(raw.Map.Transform, x, y)350 name := p.Name351 if name == "" {352 name = typeLabel353 }354 at := doc.SyntheticPosition(pixelX, pixelY)355 collection.Features = append(collection.Features, doc.Feature{356 ID: featureID,357 Title: name,358 Description: p.Description,359 At: &at,360 })361 }362 return collection, true, nil363}364365// attachArtwork reads the archived icon for every collection that names one,366// once per key, in the order the collections name them.367func (s Source) attachArtwork(a *archive.Archive, v archive.VolumeRef, out *doc.Document) error {368 seen := make(map[string]bool)369 for _, world := range out.Worlds {370 for _, collection := range world.Collections {371 if collection.Icon == "" || seen[collection.Icon] {372 continue373 }374 seen[collection.Icon] = true375 file, data, err := a.Artwork(v, collection.Icon, artworkExtensions...)376 if err != nil {377 return err378 }379 if file == "" {380 continue381 }382 out.Icons = append(out.Icons, doc.Icon{Key: collection.Icon, File: file, Data: data})383 }384 }385 return nil386}387388// worldPixel runs a pin's game coordinates through the map's transformation onto389// the zoom-zero tile, then scales to the world square.390func worldPixel(transform Transform, x, y float64) (float64, float64) {391 const zoomZeroSpan = 256392 scale := float64(doc.SyntheticWorldSize) / zoomZeroSpan393 return (transform.A*x + transform.B) * scale, (transform.C*y + transform.D) * scale394}395396// deepestWindowCenter is the middle of the deepest window the crawler observed,397// which is where a reader opens: Piggyback publishes no opening view, and the398// tiles it actually served are the best account of where the map is.399func deepestWindowCenter(raw *capture) (float64, float64) {400 deepest := raw.Levels[len(raw.Levels)-1]401 span := float64(doc.SyntheticWorldSize) / float64(int(1)<<deepest.Zoom)402 x := (float64(deepest.MinX) + float64(deepest.MaxX+1)) / 2 * span403 y := (float64(deepest.MinY) + float64(deepest.MaxY+1)) / 2 * span404 return x, y405}406407// frameOf declares the pyramid the crawler actually surveyed. Piggyback408// publishes no bounds of its own, so what the crawler saw answer is the only409// account there is of where the picture is drawn -- which is why a capture with410// no observed level is refused before this is ever called.411func frameOf(raw *capture) *doc.Frame {412 deepest := raw.Levels[len(raw.Levels)-1]413 frame := &doc.Frame{414 MinZoom: raw.Levels[0].Zoom,415 MaxZoom: deepest.Zoom,416 Format: TileExtension(raw.Map.TileServer),417 Windows: make(map[string]doc.TileWindow, len(raw.Levels)),418 }419 for _, observed := range raw.Levels {420 frame.Windows[strconv.Itoa(observed.Zoom)] = doc.TileWindow{421 MinX: observed.MinX, MinY: observed.MinY,422 MaxX: observed.MaxX, MaxY: observed.MaxY,423 }424 }425 return frame426}427428// TileSetPath cuts the layer path out of the tile URL template: everything429// between "/tiles/" and the zoom placeholder, which is how tile records group430// into pyramids downstream. It is exported because the crawler writes tiles431// under it and the reader names it, and the two have to agree.432func TileSetPath(template string) (string, error) {433 _, rest, found := strings.Cut(template, "/tiles/")434 if !found {435 return "", fmt.Errorf("tile template %q has no /tiles/ segment", template)436 }437 path, _, found := strings.Cut(rest, "/{z}")438 if !found || path == "" {439 return "", fmt.Errorf("tile template %q names no layer before its zoom", template)440 }441 return path, nil442}443444// TileExtension reads the image format off the tile URL template.445func TileExtension(template string) string {446 if dot := strings.LastIndex(template, "."); dot >= 0 && dot < len(template)-1 {447 return strings.ToLower(template[dot+1:])448 }449 return "webp"450}451452func index(in []label) map[string]string {453 out := make(map[string]string, len(in))454 for _, l := range in {455 out[l.Key] = l.Label456 }457 return out458}459460func spelled(index map[string]string, key string) string {461 if l, ok := index[key]; ok && l != "" {462 return l463 }464 return doc.Title(key)465}466467func named(given, slug string) string {468 if given != "" {469 return given470 }471 return doc.Title(slug)472}473474func sortedKeys(byType map[string][]pin) []string {475 keys := make([]string, 0, len(byType))476 for key := range byType {477 keys = append(keys, key)478 }479 sort.Strings(keys)480 return keys481}