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 / tools / generate / normalize.go
normalize.go 9.1K
1package main23import (4 "fmt"5 "hash/fnv"67 "github.com/FelineStateMachine/atlas/internal/semconv"8)910// A map's contents used to mirror the MapGenie shape all the way through the11// generator: pins under groups and categories, regions standing apart as12// zones, and every pass over a map written twice because of it. The catalog13// holds one model instead -- collections of features, each feature a14// geometry with attributes -- and since format v3 the wire says the same15// thing the model does, so what this file normalizes is what a payload16// carries.1718// A collection's kind is the dimensionality of its features.19const (20 kindPoint = "point"21 kindPath = "path"22 kindArea = "area"23)2425// regionsCollectionKey names the implicit collection every world's regions26// fold into. MapGenie never declared its regions as a collection, so the27// generator declares one for them.28const regionsCollectionKey = "regions"2930// worldCollection is one legend entry's worth of features: a category of31// pins, or a curated set of shapes, or the world's undeclared regions32// gathered under the implicit key.33type worldCollection struct {34 ID int6435 // Key is the collection's curation and merge identity; legacy categories36 // carry none and are still met by their icon key instead.37 Key string38 Title string39 // Group is the legend section this collection files under. Shape40 // collections usually carry none and gather in the viewer's own section.41 Group string42 // Kind is "point", "path" or "area".43 Kind string44 Icon, IconAsset string45 IconPicture bool46 Color, IconColor string47 // DisplayType is MapGenie's legacy render field, never emitted: it exists48 // only so speakConventions can derive atlas.render.as for captures from49 // before the conventions.50 DisplayType string51 Visible bool52 Attrs map[string]string53 Features []feature54}5556// feature is one thing on the map, whatever its dimensionality: a point57// carries Lat and Lng, a shape carries Geometry, and everything else --58// prose, attributes, shard, nesting -- reads the same for both.59type feature struct {60 ID int6461 Title string62 Subtitle string63 Description string64 Lat, Lng float6465 Geometry []geometry66 // Member is the area feature containing this point -- the region a67 // MapGenie pin names -- where Parent nests one shape inside another.68 Member *int6469 Parent *int6470 Center *coordinate71 Shard int6472 // HasText is set at packing time, when the Description defers into the73 // text payload and this marker is what the wire keeps of it.74 HasText bool75 Links []catalogLink76 Attrs map[string]string77}7879// normalizeWorld folds a decoded capture into the uniform model: each80// category becomes a point collection carrying its resolved colours, and81// every region becomes a shape feature of the collection it claims -- a82// declared one when the region names it, the implicit region collection when83// it names nothing. Declared collections follow the point collections in84// declaration order, the implicit collection last, and a region naming a85// collection nobody declared fails the build rather than guessing. A region86// whose geometry all came through empty is dropped here, exactly as it always87// was, because a shape with nothing to draw is not a shape.88func normalizeWorld(raw rawMap) ([]worldCollection, error) {89 var collections []worldCollection90 for _, rawGroup := range raw.Groups {91 for _, rawCategory := range rawGroup.Categories {92 collection := worldCollection{93 ID: rawCategory.ID,94 Title: rawCategory.Title,95 Group: rawGroup.Title,96 Kind: kindPoint,97 Icon: rawCategory.Icon,98 Color: resolvedCategoryColor(rawGroup, rawCategory),99 IconColor: resolvedIconColor(rawGroup, rawCategory),100 DisplayType: rawCategory.DisplayType,101 Visible: rawCategory.Visible,102 Attrs: rawCategory.Attrs,103 }104 for _, rawLocation := range rawCategory.Locations {105 lat, err := number(rawLocation.Latitude)106 if err != nil {107 return nil, fmt.Errorf("location %d latitude: %w", rawLocation.ID, err)108 }109 lng, err := number(rawLocation.Longitude)110 if err != nil {111 return nil, fmt.Errorf("location %d longitude: %w", rawLocation.ID, err)112 }113 collection.Features = append(collection.Features, feature{114 ID: rawLocation.ID,115 Title: rawLocation.Title,116 Description: rawLocation.Description,117 Lat: lat,118 Lng: lng,119 Member: rawLocation.RegionID,120 Attrs: rawLocation.Attrs,121 })122 }123 collections = append(collections, collection)124 }125 }126 declared := make([]worldCollection, 0, len(raw.Collections))127 declaredAt := make(map[string]int, len(raw.Collections))128 for _, decl := range raw.Collections {129 if decl.Key == "" || decl.Key == regionsCollectionKey {130 return nil, fmt.Errorf("collection %q declares key %q, which the implicit collection reserves",131 decl.Title, decl.Key)132 }133 if _, doubled := declaredAt[decl.Key]; doubled {134 return nil, fmt.Errorf("collection %q is declared twice", decl.Key)135 }136 kind := decl.Attrs[semconv.KeyGeometryKind]137 if kind != kindPath && kind != kindArea {138 return nil, fmt.Errorf("collection %q declares geometry kind %q; a declared collection must say %q or %q",139 decl.Key, kind, kindPath, kindArea)140 }141 if _, stroked := decl.Attrs[semconv.KeyStrokeWidthPx]; kind == kindPath && !stroked {142 return nil, fmt.Errorf("path collection %q declares no %s; a path is a line and a width",143 decl.Key, semconv.KeyStrokeWidthPx)144 }145 declaredAt[decl.Key] = len(declared)146 declared = append(declared, worldCollection{147 Key: decl.Key,148 Title: decl.Title,149 Kind: kind,150 Visible: true,151 Attrs: decl.Attrs,152 })153 }154 implicit := worldCollection{155 Key: regionsCollectionKey,156 Title: "Regions",157 Kind: kindArea,158 Visible: true,159 }160 for _, rawRegion := range raw.Regions {161 shape := feature{162 ID: rawRegion.ID,163 Title: rawRegion.Title,164 Subtitle: rawRegion.Subtitle,165 Description: rawRegion.Description,166 Parent: rawRegion.ParentRegionID,167 Attrs: rawRegion.Attrs,168 }169 centerX, hasX, err := optionalNumber(rawRegion.CenterX)170 if err != nil {171 return nil, fmt.Errorf("region %d center_x: %w", rawRegion.ID, err)172 }173 centerY, hasY, err := optionalNumber(rawRegion.CenterY)174 if err != nil {175 return nil, fmt.Errorf("region %d center_y: %w", rawRegion.ID, err)176 }177 if hasX && hasY {178 shape.Center = &coordinate{Latitude: centerY, Longitude: centerX}179 }180 home := &implicit181 if rawRegion.Collection != "" {182 at, known := declaredAt[rawRegion.Collection]183 if !known {184 return nil, fmt.Errorf("region %q claims collection %q, which the map never declares",185 rawRegion.Title, rawRegion.Collection)186 }187 home = &declared[at]188 }189 for _, part := range rawRegion.Features {190 if part.Geometry.Type == "" || len(part.Geometry.Coordinates) == 0 {191 continue192 }193 // The geometry sniff is dead: a line belongs to a declared path194 // collection, which says so up front, and never to the implicit195 // collection, whose regions are ground.196 if home == &implicit && part.Geometry.Type == "MultiLineString" {197 return nil, fmt.Errorf(198 "region %q draws lines in the implicit region collection; lines must be declared as a path collection",199 rawRegion.Title)200 }201 shape.Geometry = append(shape.Geometry, part.Geometry)202 }203 if len(shape.Geometry) > 0 {204 home.Features = append(home.Features, shape)205 }206 }207 // Shape collections need numeric identities of their own on the wire --208 // the viewer's hide and unfold sets are keyed by them -- and a source209 // declares keys, not numbers, so the numbers are derived from the same210 // stable names the way every other id in the pipeline is.211 used := make(map[int64]string, len(collections))212 for _, collection := range collections {213 used[collection.ID] = collection.Title214 }215 claim := func(key string) (int64, error) {216 hash := fnv.New32a()217 fmt.Fprintf(hash, "%d:collection:%s", raw.ID, key)218 id := int64(hash.Sum32() & 0x7fffffff)219 if id == 0 {220 id = 1221 }222 if holder, taken := used[id]; taken {223 return 0, fmt.Errorf("collection %q collides with %q on id %d", key, holder, id)224 }225 used[id] = key226 return id, nil227 }228 for _, collection := range declared {229 if len(collection.Features) == 0 {230 continue231 }232 id, err := claim(collection.Key)233 if err != nil {234 return nil, err235 }236 collection.ID = id237 collections = append(collections, collection)238 }239 if len(implicit.Features) > 0 {240 id, err := claim(implicit.Key)241 if err != nil {242 return nil, err243 }244 implicit.ID = id245 collections = append(collections, implicit)246 }247 return collections, nil248}249250// featureTally counts the map's features by kind: the origin account's view251// of what the map holds, the manifest's per-kind counts, and the yardstick252// every merge audit measures the composed map against.253func (m catalogWorld) featureTally() featureCounts {254 var counts featureCounts255 for _, collection := range m.Collections {256 switch collection.Kind {257 case kindPoint:258 counts.Point += len(collection.Features)259 case kindPath:260 counts.Path += len(collection.Features)261 default:262 counts.Area += len(collection.Features)263 }264 }265 return counts266}