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 / nasabluemarble / features.go
features.go 6.9K
1package nasabluemarble23import (4 "encoding/json"5 "fmt"6 "math"7 "sort"8 "strconv"910 "github.com/FelineStateMachine/atlas/format/semconv"11 "github.com/FelineStateMachine/atlas/internal/generate/doc"12)1314// The vector half of the volume: country borders as one quiet area collection,15// and the primary capitals as one point collection per continent. The16// arrangement is the capture's own -- continents are the publication's filing,17// capitals ride in the publication's order -- and the one judgement made here18// is a join: a capital whose country is not drawn at this scale still belongs19// to a continent, and the nearest drawn ground says which.2021// capitalIcon is the library glyph every capital collection names, resolved to22// embedded bytes by composition.23const capitalIcon = "maki/marker"2425// collectionsOf arranges the capture's features the way the legend reads:26// capitals first -- one collection per continent, sorted, under one heading --27// and the ground they stand on after, as a single quiet area collection.28func collectionsOf(raw *capture, ids *doc.IDSpace) ([]doc.Collection, error) {29 countryIDs := make(map[string]int64, len(raw.Features.Countries))30 for _, entry := range raw.Features.Countries {31 id, err := ids.Claim("ne:country:" + entry.A3)32 if err != nil {33 return nil, err34 }35 countryIDs[entry.A3] = id36 }3738 byContinent := make(map[string][]doc.Feature)39 var order []string40 for _, entry := range raw.Features.Capitals {41 continent := continentOf(entry, raw.Features.Countries)42 id, err := ids.Claim("ne:capital:" + entry.A3 + ":" + doc.Slugify(entry.Name))43 if err != nil {44 return nil, err45 }46 at := worldPoint(entry.Lon, entry.Lat)47 if _, seen := byContinent[continent]; !seen {48 order = append(order, continent)49 }50 byContinent[continent] = append(byContinent[continent], doc.Feature{51 ID: id,52 Title: entry.Name,53 Subtitle: entry.Country,54 At: &at,55 // The pin stands on its country's ground where that ground is56 // drawn; a capital whose country is below this scale stands on57 // open map, member zero.58 Member: countryIDs[entry.A3],59 // The coordinates as the publication gives them, verbatim.60 Attrs: map[string]string{61 semconv.KeyGeoLat: strconv.FormatFloat(entry.Lat, 'f', -1, 64),62 semconv.KeyGeoLon: strconv.FormatFloat(entry.Lon, 'f', -1, 64),63 },64 })65 }66 sort.Strings(order)6768 out := make([]doc.Collection, 0, len(order)+1)69 for _, continent := range order {70 id, err := ids.Claim("ne:capitals:" + doc.Slugify(continent))71 if err != nil {72 return nil, err73 }74 out = append(out, doc.Collection{75 ID: id,76 Title: continent,77 Group: "Capitals",78 Kind: doc.KindPoint,79 Visible: true,80 Attrs: map[string]string{81 semconv.KeyRenderAs: semconv.RenderAsPin,82 semconv.KeyIconStd: capitalIcon,83 },84 Features: byContinent[continent],85 })86 }8788 countries, err := countriesCollection(raw, ids, countryIDs)89 if err != nil {90 return nil, err91 }92 return append(out, countries), nil93}9495// countriesCollection is the ground: every country's rings as one area96// collection, labels quiet so the picture stays a picture until a border is97// asked about.98func countriesCollection(raw *capture, ids *doc.IDSpace, countryIDs map[string]int64) (doc.Collection, error) {99 id, err := ids.Claim("ne:countries")100 if err != nil {101 return doc.Collection{}, err102 }103 collection := doc.Collection{104 ID: id,105 Title: "Countries",106 Kind: doc.KindArea,107 Visible: true,108 Attrs: map[string]string{109 semconv.KeyLabelPolicy: semconv.LabelQuiet,110 },111 Features: make([]doc.Feature, 0, len(raw.Features.Countries)),112 }113 for _, entry := range raw.Features.Countries {114 geometry, err := worldGeometry(entry.Polygons)115 if err != nil {116 return doc.Collection{}, fmt.Errorf("country %q: %w", entry.Name, err)117 }118 center := worldPoint(entry.LabelLon, entry.LabelLat)119 collection.Features = append(collection.Features, doc.Feature{120 ID: countryIDs[entry.A3],121 Title: entry.Name,122 Center: &center,123 Geometry: []doc.Geometry{geometry},124 Attrs: map[string]string{125 semconv.KeyGeoLat: strconv.FormatFloat(entry.LabelLat, 'f', -1, 64),126 semconv.KeyGeoLon: strconv.FormatFloat(entry.LabelLon, 'f', -1, 64),127 },128 })129 }130 return collection, nil131}132133// worldPoint lands published coordinates on the picture. Unlike the shared134// EquirectWorldPixel it does not wrap: the publication speaks -180..180 and135// splits its rings at the antimeridian, so the mapping is linear and a vertex136// pinned to either edge of the world stays on its own edge instead of folding137// onto the other.138func worldPoint(lon, lat float64) doc.Position {139 x := (lon + 180) / 360 * doc.SyntheticWorldSize140 y := (90 - lat) / 180 * (doc.SyntheticWorldSize / 2)141 return doc.SyntheticPosition(x, y)142}143144// worldGeometry converts a country's rings into the volume's own space, as one145// MultiPolygon. Positions are rounded to a millionth of a degree -- far inside146// a world pixel -- so the payload does not spend fifteen digits saying what147// six already say.148func worldGeometry(polygons [][][][2]float64) (doc.Geometry, error) {149 if len(polygons) == 0 {150 return doc.Geometry{}, fmt.Errorf("draws no ground")151 }152 converted := make([][][][2]float64, len(polygons))153 for p, polygon := range polygons {154 if len(polygon) == 0 {155 return doc.Geometry{}, fmt.Errorf("draws an empty polygon")156 }157 converted[p] = make([][][2]float64, len(polygon))158 for r, ring := range polygon {159 converted[p][r] = make([][2]float64, len(ring))160 for v, vertex := range ring {161 at := worldPoint(vertex[0], vertex[1])162 converted[p][r][v] = [2]float64{rounded(at.Lng), rounded(at.Lat)}163 }164 }165 }166 coordinates, err := json.Marshal(converted)167 if err != nil {168 return doc.Geometry{}, err169 }170 return doc.Geometry{Type: "MultiPolygon", Coordinates: coordinates}, nil171}172173func rounded(value float64) float64 {174 return math.Round(value*1e6) / 1e6175}176177// continentOf answers which continent a capital belongs to: its country's178// filing when the country is drawn at this scale, and the nearest drawn179// ground's otherwise. Twenty-nine capitals are microstates the 1:110m borders180// leave out -- Singapore, Monaco, the island states -- and the nearest vertex181// of any drawn country decides for them, computed from the captured polygons182// at translate time the way the city source computes its watershed join.183func continentOf(entry capital, countries []country) string {184 for _, held := range countries {185 if held.A3 == entry.A3 {186 return held.Continent187 }188 }189 nearest, best := "", math.Inf(1)190 scale := math.Cos(entry.Lat * math.Pi / 180)191 for _, held := range countries {192 for _, polygon := range held.Polygons {193 for _, ring := range polygon {194 for _, vertex := range ring {195 dLon := math.Abs(vertex[0] - entry.Lon)196 if dLon > 180 {197 dLon = 360 - dLon198 }199 dLat := vertex[1] - entry.Lat200 distance := dLat*dLat + dLon*scale*dLon*scale201 if distance < best {202 best, nearest = distance, held.Continent203 }204 }205 }206 }207 }208 return nearest209}