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 / ign / ign.go
ign.go 12.4K
1// Package ign reads a captured IGN wikimap and translates it into the Atlas2// interchange document.3//4// IGN publishes community wikimaps as a flat image tiled like a world, markers5// placed on it in image-relative coordinates, and a flat list of marker types6// that names a parent to make two levels of legend. This package is the only7// thing in Atlas that knows any of that.8//9// # The coordinate design10//11// A wikimap's image is normalized so its taller dimension spans 1. A marker's12// latitude runs down that span and is negative, its longitude across it, so a13// marker at (lng, -lat) times the world square's edge is the pixel it is drawn14// on. The pixel becomes a synthetic position through the same inverse Mercator15// every picture-publishing source uses, and the raster and the markers land in16// one space.17//18// # The gate: an embedded MapGenie map is not an IGN map19//20// Some of IGN's wikimap pages are MapGenie maps in an IGN frame -- the page21// declares a MapGenie game id and serves MapGenie's tiles. Capturing one through22// this source would archive a second, worse copy of data Atlas already reads23// properly, and a merge would then fold a source into itself. The refusal lives24// at capture, where the page's own declaration is still in front of the crawler25// (internal/generate/crawl), because by the time a capture exists the evidence26// has been thrown away. What this reader can still refuse it does: a capture of27// another source's kind, a capture naming no map, a map with no size, a type28// declared twice, a marker of a type nothing declared.29//30// # Determinism31//32// Types sort by slug and markers by id -- the crawler does it before archiving,33// and this reader does it again because an archive holds captures older than the34// habit. Legend order then follows the capture: types in slug order, gathered35// under the heading of whichever parent each names, headings in the order their36// first type appears.37package ign3839import (40 "encoding/json"41 "errors"42 "fmt"43 "log/slog"44 "math"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 an IGN capture, and source is what a55// well-formed capture says it is.56const (57 captureKind = "ign-map"58 source = "ign-wikimaps"59)6061// lensName rides into a reader's lens picker -- and, when this raster is62// resampled into a finer source's world, it names the aligned variant there too,63// so a reader can always see where a picture came from.64const lensName = "IGN Wiki"6566// artworkExtensions are the forms the archive holds a type's icon in. IGN serves67// PNG sprites; the SVG probe is there so a hand-drawn replacement wins.68var artworkExtensions = []string{".svg", ".png"}6970// Source reads IGN wikimap captures.71type Source struct{}7273// New builds the source. It holds no state.74func New() Source { return Source{} }7576// Describe is the source's account of itself.77func (Source) Describe() doc.Provenance {78 return doc.Provenance{79 Name: "ign-wiki",80 // The archive files these captures under the publisher's own81 // abbreviation; Atlas names the source for the thing it publishes.82 ArchivedAs: "ign",83 Label: "IGN Wiki",84 License: "All rights reserved. IGN's wikimaps are editorial work published " +85 "under ign.com's terms; a volume carrying them is for personal use.",86 Attribution: "Map imagery and marker data by IGN and its wiki contributors, " +87 "captured from ign.com.",88 // IGN numbers markers with opaque strings and types with slugs, neither89 // of which a bundle's wire can carry, so every identity is minted from90 // a stable name.91 IDSpace: doc.IDSpaceDerived,92 }93}9495// Translate reads one archived volume.96func (s Source) Translate(a *archive.Archive, v archive.VolumeRef, log *slog.Logger) (doc.Document, error) {97 log = log.With(logging.Source("ign"))98 worlds, err := a.Worlds(v)99 if err != nil {100 return doc.Document{}, err101 }102 out := doc.Document{103 Doc: doc.Doc,104 Version: doc.Version,105 Source: s.Describe(),106 }107 for _, ref := range worlds {108 world, volume, err := s.translateWorld(a, ref, log)109 if err != nil {110 if errors.Is(err, archive.ErrNotReady) {111 log.Debug("world skipped", logging.Path(archive.TrimRoot(a.Root(), ref.Dir())),112 "reason", err.Error())113 continue114 }115 return doc.Document{}, err116 }117 if out.Volume.Slug == "" {118 // The plain slug, deliberately shared with every other source's119 // capture of the same game: their volumes answer for one library120 // entry, and the newest capture is the one a reader sees.121 out.Volume = volume122 }123 out.Worlds = append(out.Worlds, world)124 }125 if len(out.Worlds) == 0 {126 return doc.Document{}, fmt.Errorf("%w: volume %s has no readable world", archive.ErrNotReady, v.Title)127 }128 if err := s.attachArtwork(a, v, &out); err != nil {129 return doc.Document{}, err130 }131 log.Info("volume translated", logging.Volume(out.Volume.Slug),132 "worlds", len(out.Worlds), "icons", len(out.Icons))133 return out, nil134}135136func (s Source) translateWorld(a *archive.Archive, ref archive.WorldRef, log *slog.Logger) (doc.World, doc.Volume, error) {137 archived, err := a.Newest(ref)138 if err != nil {139 return doc.World{}, doc.Volume{}, err140 }141 if archived.Kind != captureKind {142 return doc.World{}, doc.Volume{}, fmt.Errorf(143 "capture %s is of kind %q; the IGN reader answers only for %q",144 archived.ContentHash, archived.Kind, captureKind)145 }146 body, err := a.Body(ref, archived)147 if err != nil {148 return doc.World{}, doc.Volume{}, err149 }150 var raw capture151 if err := json.Unmarshal(body, &raw); err != nil {152 return doc.World{}, doc.Volume{}, fmt.Errorf("decode capture %s: %w", archived.ContentHash, err)153 }154 switch {155 case raw.Source != source:156 return doc.World{}, doc.Volume{}, fmt.Errorf(157 "capture %s says its source is %q, not %q", archived.ContentHash, raw.Source, source)158 case raw.ObjectSlug == "" || raw.MapSlug == "":159 return doc.World{}, doc.Volume{}, fmt.Errorf("capture %s names no map", archived.ContentHash)160 case raw.Map.Width <= 0 || raw.Map.Height <= 0:161 return doc.World{}, doc.Volume{}, fmt.Errorf("capture %s draws a map with no size", archived.ContentHash)162 }163 raw.normalize()164165 ids := doc.NewIDSpace()166 scope := raw.ObjectSlug + "/" + raw.MapSlug167 worldID, err := ids.Claim("ign:map:" + scope)168 if err != nil {169 return doc.World{}, doc.Volume{}, err170 }171 collections, err := collectionsOf(&raw, ids, scope)172 if err != nil {173 return doc.World{}, doc.Volume{}, fmt.Errorf("map %s: %w", scope, err)174 }175176 world := doc.World{177 ID: worldID,178 Slug: raw.MapSlug,179 Title: named(raw.MapTitle, raw.MapSlug),180 Center: imagePosition(raw.Map.InitialLng, raw.Map.InitialLat),181 Capture: doc.Capture{182 Kind: archived.Kind,183 ID: archived.SourceID,184 Locator: archived.SourceURL,185 ContentHash: archived.ContentHash,186 CapturedAt: archived.CapturedAt,187 },188 Lenses: []doc.Lens{{189 Name: lensName,190 TileSet: scope,191 Frame: frameOf(&raw),192 }},193 Collections: collections,194 }195 log.Debug("world translated", logging.World(raw.MapSlug),196 "collections", len(collections), "capture", archived.ContentHash)197 return world, doc.Volume{Slug: raw.ObjectSlug, Title: raw.GameTitle}, nil198}199200// collectionsOf arranges IGN's flat marker types into the legend order a201// document carries: one collection per type, gathered under the heading of the202// parent it names, headings in the order their first type appears.203//204// A type no marker uses is left out -- an empty collection would only dim the205// legend -- but it stays in the capture, so a marker appearing under it later206// revives the collection without a policy change.207func collectionsOf(raw *capture, ids *doc.IDSpace, scope string) ([]doc.Collection, error) {208 declared := make(map[string]bool, len(raw.Types))209 for _, t := range raw.Types {210 if declared[t.TypeSlug] {211 return nil, fmt.Errorf("type %q is declared twice", t.TypeSlug)212 }213 declared[t.TypeSlug] = true214 }215 byType := make(map[string][]marker, len(raw.Types))216 for _, m := range raw.Markers {217 if !declared[m.TypeSlug] {218 return nil, fmt.Errorf("marker %s is of type %q, which nothing declares", m.ID, m.TypeSlug)219 }220 byType[m.TypeSlug] = append(byType[m.TypeSlug], m)221 }222223 // Grouped by heading, then flattened in heading order: the flat array is the224 // legend, and a heading is a string on the collections that share it.225 grouped := make(map[string][]doc.Collection)226 var headings []string227 for _, t := range raw.Types {228 markers := byType[t.TypeSlug]229 if len(markers) == 0 {230 continue231 }232 id, err := ids.Claim("ign:type:" + scope + ":" + t.TypeSlug)233 if err != nil {234 return nil, err235 }236 collection := doc.Collection{237 ID: id,238 Title: t.TypeName,239 Kind: doc.KindPoint,240 Icon: t.TypeSlug,241 Visible: true,242 Attrs: map[string]string{semconv.KeyRenderAs: semconv.RenderAsPin},243 }244 for _, m := range markers {245 featureID, err := ids.Claim("ign:marker:" + m.ID)246 if err != nil {247 return nil, err248 }249 at := imagePosition(m.Lng, m.Lat)250 collection.Features = append(collection.Features, doc.Feature{251 ID: featureID,252 Title: m.MarkerName,253 At: &at,254 })255 }256 if _, seen := grouped[t.ParentTypeSlug]; !seen {257 headings = append(headings, t.ParentTypeSlug)258 }259 grouped[t.ParentTypeSlug] = append(grouped[t.ParentTypeSlug], collection)260 }261262 var out []doc.Collection263 for _, heading := range headings {264 title := "Markers"265 if heading != "" {266 title = doc.Title(heading)267 }268 for _, collection := range grouped[heading] {269 collection.Group = title270 out = append(out, collection)271 }272 }273 return out, nil274}275276// attachArtwork reads the archived icon for every collection that names one,277// once per key, in the order the collections name them.278func (s Source) attachArtwork(a *archive.Archive, v archive.VolumeRef, out *doc.Document) error {279 seen := make(map[string]bool)280 for _, world := range out.Worlds {281 for _, collection := range world.Collections {282 if collection.Icon == "" || seen[collection.Icon] {283 continue284 }285 seen[collection.Icon] = true286 file, data, err := a.Artwork(v, collection.Icon, artworkExtensions...)287 if err != nil {288 return err289 }290 if file == "" {291 continue292 }293 out.Icons = append(out.Icons, doc.Icon{Key: collection.Icon, File: file, Data: data})294 }295 }296 return nil297}298299// imagePosition lands a marker on the picture. IGN measures down the image with300// a negative latitude and across it with a positive longitude, both against an301// image whose taller dimension spans 1, so the world square's edge scales them302// into pixels.303func imagePosition(lng, lat float64) doc.Position {304 return doc.SyntheticPosition(lng*doc.SyntheticWorldSize, -lat*doc.SyntheticWorldSize)305}306307// frameOf declares the wikimap's pyramid for the deriver. Every level is308// declared, because a wikimap is not cut from the corpus's shared window and a309// level left unsaid would be measured against a window it does not sit in.310func frameOf(raw *capture) *doc.Frame {311 frame := &doc.Frame{312 MinZoom: raw.Map.MinZoom,313 MaxZoom: raw.Map.MaxZoom,314 Format: TileExtension(raw.Map.Tileset),315 Windows: make(map[string]doc.TileWindow, raw.Map.MaxZoom+1),316 }317 for zoom := 0; zoom <= raw.Map.MaxZoom; zoom++ {318 maxX, maxY := LevelExtent(raw.Map.Width, raw.Map.Height, zoom)319 frame.Windows[strconv.Itoa(zoom)] = doc.TileWindow{MaxX: maxX, MaxY: maxY}320 }321 return frame322}323324func named(given, slug string) string {325 if given != "" {326 return given327 }328 return doc.Title(slug)329}330331// TileSetPath is the path a wikimap's tiles are captured under. It is exported332// because the crawler writes tiles at it and the reader names it, and the two333// have to agree.334func TileSetPath(objectSlug, mapSlug string) string { return objectSlug + "/" + mapSlug }335336// LevelExtent reports the last tile column and row holding any of the image at a337// zoom. The crawler asks for exactly these tiles and the deriver expects exactly338// these bounds, because it is the same call.339func LevelExtent(width, height float64, zoom int) (maxX, maxY int) {340 across := float64(int(1) << zoom)341 return lastTile(width, across), lastTile(height, across)342}343344func lastTile(extent, across float64) int {345 last := int(math.Ceil(extent*across)) - 1346 if last < 0 {347 return 0348 }349 return last350}351352// TileExtension reads the image format off IGN's tile URL template.353func TileExtension(template string) string {354 if dot := strings.LastIndex(template, "."); dot >= 0 && dot < len(template)-1 {355 return strings.ToLower(template[dot+1:])356 }357 return "jpg"358}