clone

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

feat/atlas-next-horizon
atlas / internal / generate / sources / mapgenie / mapgenie.go
mapgenie.go 14.5K
1// Package mapgenie reads MapGenie's archived captures and translates them into2// the Atlas interchange document.3//4// MapGenie publishes community game maps as a slippy map: an image tiled like a5// world, positions encoded as latitude and longitude that are really pixels, and6// a legend of groups holding categories holding pins. A capture is one map's7// whole API response, archived verbatim; this package is the only thing in8// Atlas that knows what is inside it.9//10// Nothing of that shape leaves this directory. Groups become a heading string11// on a collection, categories become collections, pins and regions become12// features of one kind or another, and the deep links MapGenie writes into its13// own prose become resolved cross-references -- because Atlas serves offline and14// a live URL in a payload is a defect.15//16// # What is refused17//18// A world with no capture, or with no tile set, is not ready: it is skipped, not19// failed, because an archive is filled by hand and a half-crawled map is a20// normal state. A capture whose kind is not this source's is a failure: reading21// another source's bytes through this reader would produce a document that22// quietly lies about where it came from.23//24// # Determinism25//26// Every ordering here comes from the capture: groups in capture order,27// categories within a group in capture order, pins within a category in capture28// order, regions in capture order. Nothing is sorted, because sorting would29// throw away an editorial order the publisher chose, and nothing is a map, so30// two runs over the same bytes emit the same document.31package mapgenie3233import (34 "encoding/json"35 "errors"36 "fmt"37 "log/slog"38 "regexp"39 "strconv"40 "strings"4142 "github.com/FelineStateMachine/atlas/format/semconv"43 "github.com/FelineStateMachine/atlas/internal/generate/archive"44 "github.com/FelineStateMachine/atlas/internal/generate/doc"45 "github.com/FelineStateMachine/atlas/internal/logging"46)4748// captureKind is what the archive calls a MapGenie map capture.49const captureKind = "map"5051// regionsKey is the collection MapGenie's regions fold into. MapGenie has no52// concept of a collection of regions -- they stand apart from the legend53// entirely -- so one is declared for them here, and the interchange document54// carries an ordinary area collection like any other.55const regionsKey = "regions"5657// artworkExtensions are the forms the archive holds a category's icon in, in58// the order they are tried. Most games publish an icon font that renders to59// SVG; some publish a marker strip that slices into PNG.60var artworkExtensions = []string{".svg", ".png"}6162// Source reads MapGenie captures.63type Source struct{}6465// New builds the source. It holds no state: a translation is a function of the66// bytes it is handed.67func New() Source { return Source{} }6869// Describe is MapGenie's account of itself.70func (Source) Describe() doc.Provenance {71 return doc.Provenance{72 Name: "mapgenie",73 Label: "MapGenie",74 Attribution: "Maps and pin data by MapGenie and its contributors, " +75 "captured from mapgenie.io.",76 // MapGenie captures carry their own numeric identities for maps,77 // categories, pins and regions, and those numbers are stable across78 // captures, so the document passes them through untouched.79 IDSpace: doc.IDSpaceNative,80 }81}8283// Translate reads one archived volume.84func (s Source) Translate(a *archive.Archive, v archive.VolumeRef, log *slog.Logger) (doc.Document, error) {85 log = log.With(logging.Source("mapgenie"))86 worlds, err := a.Worlds(v)87 if err != nil {88 return doc.Document{}, err89 }90 out := doc.Document{91 Doc: doc.Doc,92 Version: doc.Version,93 Volume: doc.Volume{Title: v.Title},94 Source: s.Describe(),95 }96 for _, ref := range worlds {97 world, slug, err := s.translateWorld(a, ref, log)98 if err != nil {99 if errors.Is(err, archive.ErrNotReady) {100 log.Debug("world skipped", logging.Path(archive.TrimRoot(a.Root(), ref.Dir())),101 "reason", err.Error())102 continue103 }104 return doc.Document{}, err105 }106 if out.Volume.Slug == "" {107 out.Volume.Slug = slug108 }109 out.Worlds = append(out.Worlds, world)110 }111 if len(out.Worlds) == 0 {112 return doc.Document{}, fmt.Errorf("%w: volume %s has no readable world", archive.ErrNotReady, v.Title)113 }114 if err := s.attachArtwork(a, v, &out); err != nil {115 return doc.Document{}, err116 }117 log.Info("volume translated", logging.Volume(out.Volume.Slug),118 "worlds", len(out.Worlds), "icons", len(out.Icons))119 return out, nil120}121122func (s Source) translateWorld(a *archive.Archive, ref archive.WorldRef, log *slog.Logger) (doc.World, string, error) {123 capture, err := a.Newest(ref)124 if err != nil {125 return doc.World{}, "", err126 }127 if capture.Kind != captureKind {128 return doc.World{}, "", fmt.Errorf(129 "capture %s is of kind %q; the MapGenie reader answers only for %q",130 capture.ContentHash, capture.Kind, captureKind)131 }132 body, err := a.Body(ref, capture)133 if err != nil {134 return doc.World{}, "", err135 }136 var raw rawMap137 if err := json.Unmarshal(body, &raw); err != nil {138 return doc.World{}, "", fmt.Errorf("decode capture %s: %w", capture.ContentHash, err)139 }140 if raw.Slug == "" {141 return doc.World{}, "", fmt.Errorf("capture %s names no map", capture.ContentHash)142 }143 if len(raw.Config.TileSets) == 0 {144 return doc.World{}, "", fmt.Errorf("%w: map %s has no tile set", archive.ErrNotReady, raw.Slug)145 }146147 world := doc.World{148 ID: raw.ID,149 Slug: raw.Slug,150 Title: raw.Title,151 Center: doc.Position{152 Lat: raw.InitialLatitude,153 Lng: raw.InitialLongitude,154 },155 Capture: doc.Capture{156 Kind: capture.Kind,157 ID: capture.SourceID,158 Locator: capture.SourceURL,159 ContentHash: capture.ContentHash,160 CapturedAt: capture.CapturedAt,161 },162 }163 for _, set := range raw.Config.TileSets {164 world.Lenses = append(world.Lenses, doc.Lens{165 Name: set.Name,166 TileSet: set.Path,167 Frame: frameOf(set),168 })169 }170 collections, err := collectionsOf(raw)171 if err != nil {172 return doc.World{}, "", fmt.Errorf("map %s: %w", raw.Slug, err)173 }174 resolveLinks(collections)175 world.Collections = collections176 log.Debug("world translated", logging.World(raw.Slug),177 "collections", len(collections), "capture", capture.ContentHash)178 return world, raw.Game.Slug, nil179}180181// collectionsOf folds a capture into the interchange document's one model.182//183// The order is the legend order and it is significant downstream: categories as184// the publisher grouped them, then the regions gathered under their own185// collection. A region whose geometry all came through empty is dropped, because186// a shape with nothing to draw is not a shape.187func collectionsOf(raw rawMap) ([]doc.Collection, error) {188 var out []doc.Collection189 for _, group := range raw.Groups {190 for _, category := range group.Categories {191 collection := doc.Collection{192 ID: category.ID,193 Title: category.Title,194 Group: group.Title,195 Kind: doc.KindPoint,196 Icon: category.Icon,197 Color: firstColor(category.Color, group.Color),198 IconColor: firstColor(category.IconColor, group.IconColor),199 Visible: category.Visible,200 // MapGenie's display type is a legacy field of its own that no201 // payload carries; it is spoken here, once, in the registered202 // vocabulary, and never travels further.203 Attrs: map[string]string{204 semconv.KeyRenderAs: semconv.RenderAs(nil, category.DisplayType),205 },206 }207 for _, location := range category.Locations {208 lat, err := number(location.Latitude)209 if err != nil {210 return nil, fmt.Errorf("location %d latitude: %w", location.ID, err)211 }212 lng, err := number(location.Longitude)213 if err != nil {214 return nil, fmt.Errorf("location %d longitude: %w", location.ID, err)215 }216 feature := doc.Feature{217 ID: location.ID,218 Title: location.Title,219 Description: location.Description,220 At: &doc.Position{Lat: lat, Lng: lng},221 }222 if location.RegionID != nil {223 feature.Member = *location.RegionID224 }225 collection.Features = append(collection.Features, feature)226 }227 out = append(out, collection)228 }229 }230231 regions := doc.Collection{232 Key: regionsKey,233 Title: "Regions",234 Kind: doc.KindArea,235 Visible: true,236 }237 for _, region := range raw.Regions {238 shape := doc.Feature{239 ID: region.ID,240 Title: region.Title,241 Subtitle: region.Subtitle,242 Description: region.Description,243 }244 if region.ParentRegionID != nil {245 shape.Parent = *region.ParentRegionID246 }247 centerX, hasX, err := optionalNumber(region.CenterX)248 if err != nil {249 return nil, fmt.Errorf("region %d center_x: %w", region.ID, err)250 }251 centerY, hasY, err := optionalNumber(region.CenterY)252 if err != nil {253 return nil, fmt.Errorf("region %d center_y: %w", region.ID, err)254 }255 if hasX && hasY {256 shape.Center = &doc.Position{Lat: centerY, Lng: centerX}257 }258 for _, part := range region.Features {259 if part.Geometry.Type == "" || len(part.Geometry.Coordinates) == 0 {260 continue261 }262 // MapGenie's regions are ground. A line drawn among them would be263 // a path collection somebody meant to declare, and this source has264 // no way to be told about one, so it is refused rather than drawn265 // as an area with no interior.266 if part.Geometry.Type == "MultiLineString" {267 return nil, fmt.Errorf("region %q draws lines, which are not ground", region.Title)268 }269 shape.Geometry = append(shape.Geometry, doc.Geometry{270 Type: part.Geometry.Type,271 Coordinates: part.Geometry.Coordinates,272 })273 }274 if len(shape.Geometry) > 0 {275 regions.Features = append(regions.Features, shape)276 }277 }278 if len(regions.Features) > 0 {279 out = append(out, regions)280 }281 return out, nil282}283284// attachArtwork reads the archived icon for every category that names one, once285// per key, in the order the collections name them.286func (s Source) attachArtwork(a *archive.Archive, v archive.VolumeRef, out *doc.Document) error {287 seen := make(map[string]bool)288 for _, world := range out.Worlds {289 for _, collection := range world.Collections {290 if collection.Kind != doc.KindPoint || collection.Icon == "" || seen[collection.Icon] {291 continue292 }293 seen[collection.Icon] = true294 file, data, err := a.Artwork(v, collection.Icon, artworkExtensions...)295 if err != nil {296 return err297 }298 if file == "" {299 continue300 }301 out.Icons = append(out.Icons, doc.Icon{Key: collection.Icon, File: file, Data: data})302 }303 }304 return nil305}306307// Labels may themselves contain a bracketed aside, as in308// "[Oh Baby! [Super Sledge]](url)", so one level of nesting is allowed.309var markdownLink = regexp.MustCompile(`\[((?:[^\[\]]|\[[^\[\]]*\])*)\]\(([^)\s]+)[^)]*\)`)310311// mapgenieLocation is how MapGenie deep-links a pin of the same map.312var mapgenieLocation = regexp.MustCompile(`locationIds=(\d+)`)313314// Some descriptions carry malformed markdown whose URL never sat inside a link,315// as in "[Boss] (Lv. 55) (https://...)". Nothing may ship a live URL, so any316// that survive link rewriting are removed outright.317var bareURL = regexp.MustCompile(`\s*\(?\s*https?://[^\s)]+\)?`)318319// resolveLinks strips every external URL out of a world's prose. Atlas ships320// with no network, so a MapGenie or YouTube link is dead weight at best. Where321// the link pointed at another pin of this same world, the target survives as a322// cross-reference a reader can follow.323func resolveLinks(collections []doc.Collection) {324 known := make(map[int64]bool)325 for _, collection := range collections {326 if collection.Kind != doc.KindPoint {327 continue328 }329 for _, feature := range collection.Features {330 known[feature.ID] = true331 }332 }333 for collectionIndex := range collections {334 collection := &collections[collectionIndex]335 if collection.Kind != doc.KindPoint {336 continue337 }338 for featureIndex := range collection.Features {339 feature := &collection.Features[featureIndex]340 if !strings.Contains(feature.Description, "http") {341 continue342 }343 feature.Description = markdownLink.ReplaceAllStringFunc(344 feature.Description,345 func(match string) string {346 parts := markdownLink.FindStringSubmatch(match)347 label, target := parts[1], parts[2]348 id := mapgenieLocation.FindStringSubmatch(target)349 if id != nil && !strings.HasPrefix(label, "!") {350 if value, err := strconv.ParseInt(id[1], 10, 64); err == nil &&351 known[value] && value != feature.ID {352 feature.Links = append(feature.Links, doc.Link{Title: label, Feature: value})353 }354 }355 return label356 },357 )358 feature.Description = strings.TrimSpace(bareURL.ReplaceAllString(feature.Description, ""))359 }360 }361}362363// firstColor resolves a category's colour over its group's, normalized. A364// capture spells colours with and without the hash and in either case, and the365// document publishes one spelling so nothing downstream has to know that.366func firstColor(category, group string) string {367 if color := normalizeHexColor(category); color != "" {368 return color369 }370 return normalizeHexColor(group)371}372373func normalizeHexColor(value string) string {374 value = strings.TrimPrefix(strings.TrimSpace(value), "#")375 switch len(value) {376 case 3, 4, 6, 8:377 default:378 return ""379 }380 for _, r := range value {381 if r >= '0' && r <= '9' || r >= 'a' && r <= 'f' || r >= 'A' && r <= 'F' {382 continue383 }384 return ""385 }386 return "#" + strings.ToUpper(value)387}388389// number reads a coordinate. MapGenie spells them as JSON numbers on some maps390// and as quoted strings on others, and both mean the same place.391func number(raw json.RawMessage) (float64, error) {392 value := strings.TrimSpace(string(raw))393 if len(value) >= 2 && value[0] == '"' {394 var text string395 if err := json.Unmarshal(raw, &text); err != nil {396 return 0, err397 }398 value = text399 }400 return strconv.ParseFloat(value, 64)401}402403func optionalNumber(raw json.RawMessage) (float64, bool, error) {404 value := strings.TrimSpace(string(raw))405 if value == "" || value == "null" {406 return 0, false, nil407 }408 n, err := number(raw)409 return n, err == nil, err410}411412// frameOf is what MapGenie says about the tiles it published: the zoom range it413// serves, the encoding it serves them in, and the window each level covers. Only414// the deriver reads it, and it reads it to know which tiles a complete level was415// supposed to hold -- a question no amount of looking at an archive can settle,416// because a level missing its last row looks exactly like a level that never had417// one.418func frameOf(set rawTileSet) *doc.Frame {419 frame := &doc.Frame{420 MinZoom: set.MinZoom,421 MaxZoom: set.MaxZoom,422 Format: set.Extension,423 Windows: make(map[string]doc.TileWindow, len(set.Bounds)),424 }425 for zoom, bound := range set.Bounds {426 frame.Windows[zoom] = doc.TileWindow{427 MinX: bound.X.Min, MinY: bound.Y.Min,428 MaxX: bound.X.Max, MaxY: bound.Y.Max,429 }430 }431 return frame432}