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 / arcgishub / window.go
window.go 4.1K
1package arcgishub23import (4 "fmt"5 "math"6 "strconv"7 "strings"89 "github.com/FelineStateMachine/atlas/internal/generate/doc"10)1112// The coordinate design, which is the whole reason a city can be a volume.13//14// A city sits off the Web-Mercator diagonal and the reader's window is a square15// upon it, so the city's bounding box -- padded, then squared in Mercator's own16// terms -- becomes the world square. A feature's true coordinates land on that17// square through the Mercator forward, and the pixel becomes a synthetic18// position through the same inverse every picture-publishing source uses. The19// distortion cancels exactly, because the rendered basemap and the features ride20// one mapping.21//22// Nothing is flattened away by it. The world declares the mapping as registered23// conventions, so any reader can run a packed position backward through it and24// stand on the ground, and every pin keeps the coordinates the city published25// beside its synthetic ones.2627// window is the ground the world square pictures: the degrees at the padded28// square's edges. The square is square in Mercator's own terms -- u linear in29// longitude, v in projected latitude -- which is what lets one number serve as30// both axes' scale.31type window struct {32 West float64 `json:"west"`33 North float64 `json:"north"`34 East float64 `json:"east"`35 South float64 `json:"south"`36}3738// mercator lands degrees on the global Web-Mercator unit square.39func mercator(lon, lat float64) (u, v float64) {40 u = (lon + 180) / 36041 v = (1 - math.Asinh(math.Tan(lat*math.Pi/180))/math.Pi) / 242 return u, v43}4445// degrees is the way back.46func degrees(u, v float64) (lon, lat float64) {47 lon = u*360 - 18048 lat = math.Atan(math.Sinh(math.Pi*(1-2*v))) * 180 / math.Pi49 return lon, lat50}5152// cityWindow pads a bounding box five percent per side and squares it in53// Mercator terms, growing the shorter axis around its own middle. The same box54// always makes the same window, which is what keeps every capture of a city in55// one world, and it is why the crawler and the reader can both compute it56// rather than the capture having to carry it.57func cityWindow(bbox [4]float64) window {58 u0, v0 := mercator(bbox[0], bbox[3]) // west, north: the top-left59 u1, v1 := mercator(bbox[2], bbox[1]) // east, south: the bottom-right60 padU, padV := (u1-u0)*0.05, (v1-v0)*0.0561 u0, u1 = u0-padU, u1+padU62 v0, v1 = v0-padV, v1+padV63 side := math.Max(u1-u0, v1-v0)64 uMid, vMid := (u0+u1)/2, (v0+v1)/265 u0, u1 = uMid-side/2, uMid+side/266 v0, v1 = vMid-side/2, vMid+side/267 west, north := degrees(u0, v0)68 east, south := degrees(u1, v1)69 return window{West: west, North: north, East: east, South: south}70}7172// worldPixel lands true coordinates on the world square.73func (w window) worldPixel(lon, lat float64) (x, y float64) {74 u0, v0 := mercator(w.West, w.North)75 u1, _ := mercator(w.East, w.South)76 side := u1 - u077 u, v := mercator(lon, lat)78 return (u - u0) / side * doc.SyntheticWorldSize, (v - v0) / side * doc.SyntheticWorldSize79}8081// pxDeg spells the window as the two world attributes that declare it: the82// raster window the cut fills, and the ground it pictures.83func (w window) pxDeg() (px, deg string) {84 px = fmt.Sprintf("0,0,%d,%d", doc.SyntheticWorldSize, doc.SyntheticWorldSize)85 parts := []string{86 strconv.FormatFloat(w.West, 'f', -1, 64),87 strconv.FormatFloat(w.North, 'f', -1, 64),88 strconv.FormatFloat(w.East, 'f', -1, 64),89 strconv.FormatFloat(w.South, 'f', -1, 64),90 }91 return px, strings.Join(parts, ",")92}9394// syntheticRing is one position's whole journey: true degrees to world pixel to95// the [longitude, latitude] pair a shape's coordinates speak.96func (w window) syntheticRing(lon, lat float64) []float64 {97 x, y := w.worldPixel(lon, lat)98 return []float64{doc.SyntheticLng(x), doc.SyntheticLat(y)}99}100101// TileSetPath is the path a city's rendered basemap is captured under. It is102// exported because the renderer writes tiles at it and the reader names it, and103// the two have to agree. The capture day is part of it: every dated world104// renders its own basemap, and a tile set is told from its neighbours by this105// path alone.106func TileSetPath(city, day string) string { return city + "/" + day + "/basemap" }