clone

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

feat: let a map say it is a sphere

Dami committed 5eb88b4
The pipeline has always projected onto a plane, and for a planet that
was a quiet lie the pin cards had to correct in prose. Now the map
itself declares what its raster pictures: a sphere, flattened by the
equirectangular projection whose window and ground ride as attributes.
The mapping is the whole story -- any reader runs a pin's packed
position backward through it and stands on the planet -- and a test
holds it to the same transform the translator projects features
through, so the declaration and the projection can never drift apart.

Each pin also carries the coordinates its source published, verbatim,
as location attributes beside its card text: provenance in structure
rather than trapped in prose. Every map that says nothing remains a
plane, which every map was until the planets arrived, and the viewer
draws spheres on its plane exactly as before -- the declared fallback
-- until a renderer arrives that can do them justice.
commit
5eb88b474ae0a43d33a521533160131e5f09cb84
parent
a61fc23562ec4edb82abb51082772a805a4210d5
author
Dami <dami.etoile@gmail.com>
3 files changed, +74 -1
Minternal/trekmap/trekmap.go28+++++++++++++++++++
Minternal/trekmap/trekmap_test.go44++++++++++++++++++++++++++++++
Mtools/generate/bundles.go3+-
M internal/trekmap/trekmap.go +28 -0
@@ -191,6 +191,19 @@ func Translate(doc []byte) ([]byte, error) {
191191 InitialLatitude: mgdoc.SyntheticLatitude(mgdoc.WorldSize / 4),192192 InitialLongitude: mgdoc.SyntheticLongitude(mgdoc.WorldSize / 2),193193 Config: mgdoc.Config{TileSets: sets},194+ // The map says what it pictures: a sphere, flattened by the equi-195+ // rectangular projection into the top half of the world square. The196+ // mapping is the whole story -- any reader can run a pin's packed197+ // position backward through it and stand on the planet -- and it is198+ // exactly the transform worldPixel projects features through, so the199+ // two can never disagree without a test noticing.200+ Attrs: map[string]string{201+ semconv.KeyGeometrySurface: semconv.SurfaceSphere,202+ semconv.KeyGeometryProjection: semconv.ProjectionEquirect,203+ semconv.KeyGeometryEquirectPx: equirectPx,204+ semconv.KeyGeometryEquirectDeg: equirectDeg,205+ semconv.KeyGeometryBody: capture.Body,206+ },194207 Game: mgdoc.Game{195208 ID: gameID,196209 // A planet is a "game" to the pipeline, which is the point being
@@ -244,6 +257,14 @@ func extension(given string) string {
244257 return "jpg"245258 }246259 260+// The declared flattening, spelled once for the map attributes and for261+// worldPixel below: the mosaic fills the top half of the world square and262+// pictures the whole ground, -180..180 west to east, 90..-90 top to bottom.263+const (264+ equirectPx = "0,0,8192,4096"265+ equirectDeg = "-180,90,180,-90"266+)267+247268 // worldPixel lands a feature's planetary coordinates on the picture. The248269 // mosaic spans longitude -180..180 west to east and latitude 90..-90 top to249270 // bottom; the Gazetteer speaks east-positive 0..360, so longitudes wrap into
@@ -350,6 +371,13 @@ func buildGroups(capture *Capture, ids *mgdoc.IDSpace, scope string) ([]mgdoc.Gr
350371 Description: describe(feature),351372 Latitude: mgdoc.SyntheticLatitude(y),352373 Longitude: mgdoc.SyntheticLongitude(x),374+ // The coordinates as the Gazetteer published them, verbatim:375+ // provenance beside the synthetic position, so no reader ever376+ // has to parse a pin's card to learn where it truly is.377+ Attrs: map[string]string{378+ semconv.KeyGeoLat: strconv.FormatFloat(feature.Latitude, 'f', -1, 64),379+ semconv.KeyGeoLon: strconv.FormatFloat(feature.Longitude, 'f', -1, 64),380+ },353381 })354382 }355383 categories = append(categories, category)
M internal/trekmap/trekmap_test.go +44 -0
@@ -10,6 +10,7 @@ import (
1010 1111 "github.com/FelineStateMachine/atlas/internal/icons"1212 "github.com/FelineStateMachine/atlas/internal/mgdoc"13+ "github.com/FelineStateMachine/atlas/internal/semconv"1314 )1415 1516 // fixture is a small capture exercising everything Translate decides: two
@@ -273,6 +274,49 @@ func TestTranslateRejectsBadCaptures(t *testing.T) {
273274 }274275 }275276 277+// TestDeclaredMappingInverts is the honesty check the geometry conventions278+// stand on: a pin's packed synthetic position, run through the viewer's279+// projection and backward through the mapping the map itself declares, must280+// recover the coordinates the Gazetteer published -- for every pin, without281+// the pin saying anything.282+func TestDeclaredMappingInverts(t *testing.T) {283+ m := translate(t, fixture())284+ mapping, err := semconv.ParseEquirect(285+ m.Attrs[semconv.KeyGeometryEquirectPx],286+ m.Attrs[semconv.KeyGeometryEquirectDeg])287+ if err != nil {288+ t.Fatal(err)289+ }290+ if m.Attrs[semconv.KeyGeometrySurface] != semconv.SurfaceSphere ||291+ m.Attrs[semconv.KeyGeometryBody] != "mars" {292+ t.Fatalf("geometry attrs: %v", m.Attrs)293+ }294+ published := map[string][2]float64{}295+ for _, feature := range fixture().Features {296+ published[feature.Name] = [2]float64{feature.Latitude, feature.Longitude}297+ }298+ for _, group := range m.Groups {299+ for _, category := range group.Categories {300+ for _, location := range category.Locations {301+ x := mgdoc.ProjectX(location.Longitude, mgdoc.SourceZoom, 0)302+ y := mgdoc.ProjectY(location.Latitude, mgdoc.SourceZoom, 0)303+ lat, lon := mapping.Invert(x, y)304+ if lon < 0 {305+ lon += 360306+ }307+ want := published[location.Title]308+ if math.Abs(lat-want[0]) > 1e-9 || math.Abs(lon-want[1]) > 1e-9 {309+ t.Errorf("%s recovered at %v,%v, published %v", location.Title, lat, lon, want)310+ }311+ // And the pin's own account agrees with what was published.312+ if location.Attrs[semconv.KeyGeoLat] == "" || location.Attrs[semconv.KeyGeoLon] == "" {313+ t.Errorf("%s carries no true coordinates", location.Title)314+ }315+ }316+ }317+ }318+}319+276320 // TestStandardIconsAreVendored holds the curation table to the library: a277321 // promise the translator writes into a capture's translation must be one the278322 // resolution pass can keep.
M tools/generate/bundles.go +2 -1