diff --git a/.gitignore b/.gitignore index 5c9e06e..7436e29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ .vscode data go_tile +test.osm.pbf +osm-tileserver +mock_data/ +*.out +*.test diff --git a/Makefile b/Makefile index 8768db0..1ad06fe 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ run: CGO_ENABLED=$(CGO_ENABLED) go run . test: - CGO_ENABLED=$(CGO_ENABLED) go test . + CGO_ENABLED=$(CGO_ENABLED) go test ./... .PHONY: \ build \ diff --git a/README.md b/README.md index b247f1d..9c71aee 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ go_tile is a drop-in replacement for [mod_tile](https://github.com/openstreetmap/mod_tile). It should work with both renderd and [tirex](https://github.com/openstreetmap/tirex), although development has thus far only been done with renderd. -go_tile is a static ~6MB binary with no external libraries, small memory footprint. +go_tile is a static ~12MB binary with few external libraries, small memory footprint. Currently supported features: * serve prerendered tiles @@ -58,24 +58,30 @@ If you prefer to run the binary directly you have the following options: Usage of ./go_tile: -data string Path to directory containing tiles (default "./data") + -host string + HTTP Listening host (default "0.0.0.0") -map string Name of map. This value is also used to determine the metatile subdirectory (default "ajt") - -port string - HTTP Listening port (default ":8080") - -renderd-timeout int - time in seconds to wait for renderd before returning an error to the client. Set negative to disable (default 60) + -osm_path string + Path to osm_path to use for direct rendering. (experimental) + -port int + HTTP Listening port (default 8080) + -renderd-timeout duration + Timeout duration after which renderd returns an error to the client (I.E. '30s' for thirty seconds). Set negative to disable (default 1m0s) -socket string - Unix domain socket path or hostname:port for contacting renderd. Set to '' to disable rendering (default "") + Unix domain socket path or hostname:port for contacting renderd. Rendering disabled by default. -static string Path to static file directory (default "./static/") -tile_expiration duration - Duration(example for a week: '168h') after which tiles are considered stale. Disabled by default + Duration after which tiles are considered stale (I.E. '168h' for one week). Tile expiration disabled by default -tls_cert_path string Path to TLS certificate -tls_key_path string Path to TLS key - -tls_port string - HTTPS Listening port. This listener is only enabled if both tls cert and key are set. (default ":8443") + -tls_port int + HTTPS Listening port. This listener is only enabled if both tls cert and key are set. (default 8443) + -verbose + Output debug log messages ``` ## Pregenerate static tiles using mod_tile @@ -132,3 +138,23 @@ Percentage of the requests served within a certain time (ms) This benchmark doesn't access the disk, as the tile has obviously been cached in memory. Anyways it should give you an indication of whether this is fast enough for your use-case. + +# Experimental renderer + +This repo also contains an experimental rudimentary renderer. Its goal is to be able to render some sort of map with little operational overhead. + +Experimental renderer (this will not be updated on every release, so ymmv): +![black lines only](assets/5295.png) +Comparison of osm.org: +![osm.org](assets/5295-compare.png) +Images [OpenStreetMap](https://www.openstreetmap.org/) contributors, [CC-BY-SA](https://creativecommons.org/licenses/by-sa/2.0/) + +To use this, pass an oms.pbf file via `-osm_path`. + +Currently osm.pbf files have to be prepared like so +``` +osmium add-locations-to-ways ~/Downloads/hamburg-latest.osm.pbf -o prepared.osm.pbf -f +``` + +This creates a temporary file on startup, which might take some time. +Testing so far was only done for individual cities. diff --git a/assets/5295-compare.png b/assets/5295-compare.png new file mode 100644 index 0000000..23eff16 Binary files /dev/null and b/assets/5295-compare.png differ diff --git a/assets/5295.png b/assets/5295.png new file mode 100644 index 0000000..d44e6c9 Binary files /dev/null and b/assets/5295.png differ diff --git a/go.mod b/go.mod index 67c35f3..df33f0f 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,18 @@ module github.com/nielsole/go_tile go 1.20 + +require github.com/paulmach/osm v0.7.1 + +require ( + git.sr.ht/~sbinet/gg v0.5.0 // indirect + github.com/campoy/embedmd v1.0.0 // indirect + github.com/datadog/czlib v0.0.0-20160811164712-4bc9a24e37f2 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/paulmach/orb v0.1.3 // indirect + github.com/paulmach/protoscan v0.2.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/image v0.7.0 // indirect + golang.org/x/text v0.9.0 // indirect + google.golang.org/protobuf v1.27.1 // indirect +) diff --git a/go.sum b/go.sum index e69de29..a878925 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,60 @@ +git.sr.ht/~sbinet/gg v0.5.0 h1:6V43j30HM623V329xA9Ntq+WJrMjDxRjuAB1LFWF5m8= +git.sr.ht/~sbinet/gg v0.5.0/go.mod h1:G2C0eRESqlKhS7ErsNey6HHrqU1PwsnCQlekFi9Q2Oo= +github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY= +github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8= +github.com/datadog/czlib v0.0.0-20160811164712-4bc9a24e37f2 h1:ISaMhBq2dagaoptFGUyywT5SzpysCbHofX3sCNw1djo= +github.com/datadog/czlib v0.0.0-20160811164712-4bc9a24e37f2/go.mod h1:2yDaWzisHKoQoxm+EU4YgKBaD7g1M0pxy7THWG44Lro= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/paulmach/orb v0.1.3 h1:Wa1nzU269Zv7V9paVEY1COWW8FCqv4PC/KJRbJSimpM= +github.com/paulmach/orb v0.1.3/go.mod h1:VFlX/8C+IQ1p6FTRRKzKoOPJnvEtA5G0Veuqwbu//Vk= +github.com/paulmach/osm v0.7.1 h1:dc84gLa4S/zCCqpBxb6jXTkN5dCI7VK7edt/tZTFG50= +github.com/paulmach/osm v0.7.1/go.mod h1:v0vZa0rKnCsO8ovx0Z+hR9BWVD+vO4ogLOXcV18/0yk= +github.com/paulmach/protoscan v0.2.1 h1:rM0FpcTjUMvPUNk2BhPJrreDKetq43ChnL+x1sRg8O8= +github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw= +golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/time v0.0.0-20190921001708-c4c64cad1fd0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= diff --git a/main.go b/main.go index a68614f..4bc9397 100644 --- a/main.go +++ b/main.go @@ -1,17 +1,22 @@ package main import ( + "context" "encoding/binary" "errors" "flag" "fmt" "io" + "io/ioutil" "net" "net/http" "os" - "regexp" - "strconv" + "os/signal" + "syscall" "time" + + "github.com/nielsole/go_tile/renderer" + "github.com/nielsole/go_tile/utils" ) /* @@ -32,8 +37,6 @@ import ( * along with this program; If not, see http://www.gnu.org/licenses/. */ -var _matcher = regexp.MustCompile(`^/tile/([0-9]+)/([0-9]+)/([0-9]+).(png|webp)$`) - func findPath(baseDir, mapName string, z, x, y uint32) (metaPath string, offset uint32) { var mask uint32 var hash [5]byte @@ -109,32 +112,8 @@ func writeTileResponse(writer http.ResponseWriter, req *http.Request, metatile_p return nil } -func parsePath(path string) (z, x, y uint32, ext string, err error) { - matches := _matcher.FindStringSubmatch(path) - if len(matches) != 5 { - return 0, 0, 0, "", errors.New("could not match path") - } - zInt, err := strconv.Atoi(matches[1]) - if err != nil { - return - } - xInt, err := strconv.Atoi(matches[2]) - if err != nil { - return - } - yInt, err := strconv.Atoi(matches[3]) - if err != nil { - return - } - ext = matches[4] - z = uint32(zInt) - x = uint32(xInt) - y = uint32(yInt) - return -} - func handleRequest(resp http.ResponseWriter, req *http.Request, data_dir, map_name, renderd_socket string, renderd_timeout time.Duration, tile_expiration time.Duration) { - z, x, y, ext, err := parsePath(req.URL.Path) + z, x, y, ext, err := utils.ParsePath(req.URL.Path) if err != nil { resp.WriteHeader(http.StatusBadRequest) resp.Write([]byte(err.Error())) @@ -195,6 +174,7 @@ func main() { data_dir := flag.String("data", "./data", "Path to directory containing tiles") static_dir := flag.String("static", "./static/", "Path to static file directory") renderd_socket := flag.String("socket", "", "Unix domain socket path or hostname:port for contacting renderd. Rendering disabled by default.") + osm_path := flag.String("osm_path", "", "Path to osm_path to use for direct rendering. (experimental)") renderd_timeout_duration := flag.Duration("renderd-timeout", (time.Duration(60) * time.Second), "Timeout duration after which renderd returns an error to the client (I.E. '30s' for thirty seconds). Set negative to disable") map_name := flag.String("map", "ajt", "Name of map. This value is also used to determine the metatile subdirectory") tls_cert_path := flag.String("tls_cert_path", "", "Path to TLS certificate") @@ -204,6 +184,10 @@ func main() { flag.Parse() + if len(*osm_path) > 0 && len(*renderd_socket) > 0 { + logFatalf("osm_path and renderd_socket are mutually exclusive") + } + // Renderd expects at most 64 bytes. // 64 - (5 * 4 bytes - 1 zero byte of null-terminated string) = 43 if len(*map_name) > 43 { @@ -225,23 +209,72 @@ func main() { } logInfof("Using renderd %s socket at '%s'\n", renderd_socket_type, *renderd_socket) } else { - logInfof("Rendering is disabled") + logInfof("renderd backend is disabled") } + var requestHandler func(http.ResponseWriter, *http.Request) + + if len(*osm_path) > 0 { + // Create a temp file. + var err error + tempFile, err := ioutil.TempFile("", "example") + if err != nil { + fmt.Println("Cannot create temp file:", err) + os.Exit(1) + } + + data, err := renderer.LoadData(*osm_path, 15, tempFile) + if err != nil { + logFatalf("There was an error loading data: %v", err) + } + tempFileName := tempFile.Name() + tempFile.Close() + + // Memory-map the file + mmapData, mmapFile, err := renderer.Mmap(tempFileName) + if err != nil { + logFatalf("There was an error memory-mapping temp file: %v", err) + } + defer renderer.Munmap(mmapData) + defer mmapFile.Close() + requestHandler = func(w http.ResponseWriter, r *http.Request) { + if *verbose { + logDebugf("%s request received: %s", r.Method, r.RequestURI) + } + if r.Method != "GET" { + http.Error(w, "Only GET requests allowed", http.StatusMethodNotAllowed) + return + } + renderer.HandleRenderRequest(w, r, *renderd_timeout_duration, data, 15, mmapData) + //handleRequest(w, r, *data_dir, *map_name, *renderd_socket, *renderd_timeout_duration, *tile_expiration_duration) + } + defer func() { + // Cleanup the temp file. + if err := os.Remove(tempFile.Name()); err != nil { + fmt.Println("Failed to remove temp file:", err) + } else { + fmt.Println("Temp file removed.") + } + + }() + } else { + + requestHandler = func(w http.ResponseWriter, r *http.Request) { + if *verbose { + logDebugf("%s request received: %s", r.Method, r.RequestURI) + } + if r.Method != "GET" { + http.Error(w, "Only GET requests allowed", http.StatusMethodNotAllowed) + return + } + handleRequest(w, r, *data_dir, *map_name, *renderd_socket, *renderd_timeout_duration, *tile_expiration_duration) + } + } // HTTP request multiplexer httpServeMux := http.NewServeMux() // Tile HTTP request handler - httpServeMux.HandleFunc("/tile/", func(w http.ResponseWriter, r *http.Request) { - if *verbose { - logDebugf("%s request received: %s", r.Method, r.RequestURI) - } - if r.Method != "GET" { - http.Error(w, "Only GET requests allowed", http.StatusMethodNotAllowed) - return - } - handleRequest(w, r, *data_dir, *map_name, *renderd_socket, *renderd_timeout_duration, *tile_expiration_duration) - }) + httpServeMux.HandleFunc("/tile/", requestHandler) // Static HTTP request handler httpServeMux.Handle("/", http.FileServer(http.Dir(*static_dir))) @@ -251,41 +284,66 @@ func main() { Handler: httpServeMux, } - // HTTPS listener - if len(*tls_cert_path) > 0 && len(*tls_key_path) > 0 { - go func() { - httpsAddr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d", *http_listen_host, *https_listen_port)) - if err != nil { - logFatalf("Failed to resolve TCP address: %v", err) - } - httpsListener, err := net.ListenTCP("tcp", httpsAddr) - if err != nil { - logFatalf("Failed to start TCP listener: %v", err) - } else { - logInfof("Started HTTPS listener on %s\n", httpsAddr) - } - err = httpServer.ServeTLS(httpsListener, *tls_cert_path, *tls_key_path) - if err != nil { - logFatalf("Failed to start HTTPS server: %v", err) - } - }() - } else { - logInfof("TLS is disabled") - } + go func() { - // HTTP listener - httpAddr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d", *http_listen_host, *http_listen_port)) - if err != nil { - logFatalf("Failed to resolve TCP address: %v", err) - } - httpListener, err := net.ListenTCP("tcp", httpAddr) - if err != nil { - logFatalf("Failed to start TCP listener: %v", err) - } else { - logInfof("Started HTTP listener on %s\n", httpAddr) - } - err = httpServer.Serve(httpListener) - if err != nil { - logFatalf("Failed to start HTTP server: %v", err) + // HTTPS listener + if len(*tls_cert_path) > 0 && len(*tls_key_path) > 0 { + go func() { + httpsAddr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d", *http_listen_host, *https_listen_port)) + if err != nil { + logFatalf("Failed to resolve TCP address: %v", err) + } + httpsListener, err := net.ListenTCP("tcp", httpsAddr) + if err != nil { + logFatalf("Failed to start TCP listener: %v", err) + } else { + logInfof("Started HTTPS listener on %s\n", httpsAddr) + } + err = httpServer.ServeTLS(httpsListener, *tls_cert_path, *tls_key_path) + if err != nil && err != http.ErrServerClosed { + logFatalf("Failed to start HTTPS server: %v", err) + } + }() + } else { + logInfof("TLS is disabled") + } + + // HTTP listener + httpAddr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d", *http_listen_host, *http_listen_port)) + if err != nil { + logFatalf("Failed to resolve TCP address: %v", err) + } + httpListener, err := net.ListenTCP("tcp", httpAddr) + if err != nil { + logFatalf("Failed to start TCP listener: %v", err) + } else { + logInfof("Started HTTP listener on %s\n", httpAddr) + } + err = httpServer.Serve(httpListener) + if err != nil && err != http.ErrServerClosed { + logFatalf("Failed to start HTTP server: %v", err) + } + }() + // Setup signal capturing. + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + + // Waiting for SIGINT (Ctrl+C) + select { + case <-stop: + fmt.Println("\nShutting down the server...") + + // Create a deadline for the shutdown process. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Start shutdown. + if err := httpServer.Shutdown(ctx); err != nil { + fmt.Println("Error during server shutdown:", err) + } + + // Additional cleanup code here... + + fmt.Println("Server gracefully stopped.") } } diff --git a/main_test.go b/main_test.go index f35a5a2..fd45986 100644 --- a/main_test.go +++ b/main_test.go @@ -6,38 +6,9 @@ import ( "net/http/httptest" "testing" "time" -) - -func TestParse(t *testing.T) { - z, x, y, ext, err := parsePath("/tile/4/3/2.webp") - if err != nil { - t.Error(err) - } - if z != 4 || x != 3 || y != 2 || ext != "webp" { - t.Fail() - } -} -func TestParseError(t *testing.T) { - invalid_paths := []string{ - "/tile/4/3/2", - "/tile/4/3/2.jpg", - "/tile/4/3/2.png/", - "/tile/4/3/2.png/3", - "/tile/4/3/2.png/3/4", - "/tile/-1/3/2.png", - "/tile/1.5/3/2.png", - "/tile/10000/3.5/2.png", - "/tile/100000000000000000000000000000000000000000000000000000000000000000/3/2.5.png", - "/tile/abc/3/2.png", - } - for _, path := range invalid_paths { - _, _, _, _, err := parsePath(path) - if err == nil { - t.Errorf("expected error for path %s", path) - } - } -} + "github.com/nielsole/go_tile/utils" +) // cpu: Intel(R) Core(TM) i5-6300U CPU @ 2.40GHz // BenchmarkPngRead-4 88027 13260 ns/op @@ -52,7 +23,7 @@ func BenchmarkPngRead(b *testing.B) { func BenchmarkParsePath(b *testing.B) { for i := 0; i < b.N; i++ { - _, _, _, _, err := parsePath("/tile/4/3/2.png") + _, _, _, _, err := utils.ParsePath("/tile/4/3/2.png") if err != nil { b.Error(err) } diff --git a/renderer/.gitignore b/renderer/.gitignore new file mode 100644 index 0000000..da5f78c --- /dev/null +++ b/renderer/.gitignore @@ -0,0 +1,3 @@ +cpu.out +*.svg +*.test diff --git a/renderer/experimental-renderer-windows.go b/renderer/experimental-renderer-windows.go new file mode 100644 index 0000000..172873c --- /dev/null +++ b/renderer/experimental-renderer-windows.go @@ -0,0 +1,27 @@ +//go:build windows +// +build windows + +package renderer + +import ( + "errors" + "net/http" + "os" + "time" +) + +func LoadData(path string, maxZ uint32, tempFile *os.File) (*Data, error) { + return nil, errors.New("not implemented on Windows") +} + +func Mmap(path string) (*[]byte, *os.File, error) { + return nil, nil, errors.New("not implemented on Windows") +} + +func HandleRenderRequest(w http.ResponseWriter, r *http.Request, duration time.Duration, data *Data, maxTreeDepth uint32, mmapData *[]byte) { + return +} + +func Munmap(data *[]byte) error { + return errors.New("not implemented on Windows") +} diff --git a/renderer/experimental-renderer.go b/renderer/experimental-renderer.go new file mode 100644 index 0000000..01c8283 --- /dev/null +++ b/renderer/experimental-renderer.go @@ -0,0 +1,239 @@ +//go:build !windows +// +build !windows + +package renderer + +import ( + "context" + "io" + "net/http" + "os" + "runtime" + "syscall" + "time" + + "git.sr.ht/~sbinet/gg" + "github.com/nielsole/go_tile/utils" + "github.com/paulmach/osm" + "github.com/paulmach/osm/osmpbf" +) + +func pointToPixels(point Point, bbox BoundingBox, pixels uint32) Pixel { + x := (point.Lon - bbox.Min.Lon) / (bbox.Max.Lon - bbox.Min.Lon) * float64(pixels) + y := float64(pixels) - (point.Lat-bbox.Min.Lat)/(bbox.Max.Lat-bbox.Min.Lat)*float64(pixels) + return Pixel{x, y} +} + +// Returns true for types of Ways that should be displayed at zoom level 0-9. +// Includes major roads, railways, waterways, and important landmarks. +func importantLandmarkZ9(tags *osm.Tags) bool { + return isImportantWay(tags) //|| isRailWay(tags) || isWaterWay(tags) || isImportantLandmark(tags) +} + +func isImportantWay(tags *osm.Tags) bool { + value := tags.Find("highway") + return value == "motorway" || value == "trunk" || value == "primary" || value == "secondary" || value == "tertiary" || value == "motorway_link" || value == "trunk_link" || value == "primary_link" || value == "secondary_link" || value == "tertiary_link" +} + +func nodeToPoint(node *osm.WayNode) Point { + return Point{node.Lon, node.Lat} +} + +func LoadData(path string, maxZ uint32, tempFile *os.File) (*Data, error) { + file, err := os.Open(path) + if err != nil { + panic(err) + } + defer file.Close() + + // The third parameter is the number of parallel decoders to use. + scanner := osmpbf.New(context.Background(), file, runtime.GOMAXPROCS(-1)) + scanner.SkipNodes = true + scanner.SkipRelations = true + defer scanner.Close() + data := Data{ + Tiles: make(map[uint64]*[]MapObjectOffset), + Filepath: tempFile.Name(), + } + + // // Creating an empty list for every layer of zoom 0-20 + // for i := 0; i < 21; i++ { + // data.Levels = append(data.Levels, make([]uint64, 0)) + // } + var i uint64 = 0 + var maxPoints int = 0 + for scanner.Scan() { + switch v := scanner.Object().(type) { + case *osm.Way: + mapObject := MapObject{ + BoundingBox: getBoundingBoxFromWay(v), + Points: make([]Point, 0), + } + for _, node := range v.Nodes { + mapObject.Points = append(mapObject.Points, nodeToPoint(&node)) + } + if len(mapObject.Points) > maxPoints { + maxPoints = len(mapObject.Points) + } + position, err := tempFile.Seek(0, io.SeekCurrent) + if err != nil { + return nil, err + } + _, err = WriteMapObject(tempFile, mapObject) + if err != nil { + return nil, err + } + containingTiles := getTilesForBoundingBox(mapObject.BoundingBox, 0, maxZ) + for _, containingTile := range containingTiles { + if !importantLandmarkZ9(&v.Tags) && (containingTile.Z < 11) { + continue + } + index := containingTile.index() + if _, ok := data.Tiles[index]; ok { + *data.Tiles[index] = append(*data.Tiles[index], MapObjectOffset(position)) + } else { + data.Tiles[index] = &[]MapObjectOffset{MapObjectOffset(position)} + } + } + i += 1 + } + + if err := scanner.Err(); err != nil { + return nil, err + } + } + data.MaxPoints = maxPoints + return &data, nil +} + +func Mmap(path string) (*[]byte, *os.File, error) { + file, err := os.Open(path) + if err != nil { + return nil, nil, err + } + defer file.Close() + fi, err := file.Stat() + if err != nil { + return nil, nil, err + } + size := int64(fi.Size()) + // Get system page size + pageSize := os.Getpagesize() + + // Calculate the number of pages needed, rounding up + pages := (size + int64(pageSize) - 1) / int64(pageSize) + + // Calculate the size in bytes + sizeInBytes := pages * int64(pageSize) + + // Memory-map the file + mmapData, err := syscall.Mmap(int(file.Fd()), 0, int(sizeInBytes), syscall.PROT_READ, syscall.MAP_SHARED) + if err != nil { + return nil, nil, err + } + return &mmapData, file, nil +} + +func Munmap(data *[]byte) error { + return syscall.Munmap(*data) +} + +func HandleRenderRequest(w http.ResponseWriter, r *http.Request, duration time.Duration, data *Data, maxTreeDepth uint32, mmapData *[]byte) { + z, x, y, ext, err := utils.ParsePath(r.URL.Path) + if ext != "png" { + http.Error(w, "Only png is supported", http.StatusBadRequest) + } + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + tile := Tile{ + X: x, + Y: y, + Z: z, + } + bbox := getBoundingBox(tile) + // fmt.Printf("Bounding box is from Lat %f, Lon %f to Lat %f, Lon %f\n", bbox.Min.Lat, bbox.Min.Lon, bbox.Max.Lat, bbox.Max.Lon) + + const S = 256 + dc := gg.NewContext(S, S) + + dc.SetRGB(1, 1, 1) + dc.Clear() + parentTile := tile + for tempZ := z; tempZ > maxTreeDepth; tempZ-- { + parentTile = parentTile.getParent() + } + wayIndices, ok := data.Tiles[parentTile.index()] + if !ok { + // Return 404 + w.WriteHeader(http.StatusNotFound) + return + } + way := MapObject{Points: make([]Point, 0, data.MaxPoints)} + for _, wayReference := range *wayIndices { + err := ReadMapObject(mmapData, int64(wayReference), &way) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // way := data.MapObjects[wayReference] + visible := false + if !bbox.overlaps(way.BoundingBox) { + continue + } + //tags := way.Tags.Map() + for i, point := range way.Points { + // Print Lat and Lng of node + //fmt.Printf("Lat: %f, Lon: %f\n", node.Lat, node.Lon) + + if bbox.contains(point) { + visible = true + } + + // We know that all previous points were outside of bounds, so we can skip them + if i != 0 && visible { + dc.SetRGB(0, 0, 0) + // if highway, ok := tags["highway"]; ok { + // switch highway { + // case "motorway": + // dc.SetRGB(0.9, 0.6, 0.6) + // dc.SetLineWidth(6) + // case "trunk": + // dc.SetRGB(0.85, 0.55, 0.55) + // dc.SetLineWidth(4) + // case "primary": + // dc.SetRGB(0.8, 0.5, 0.5) + // dc.SetLineWidth(3) + // case "secondary": + // dc.SetRGB(0.75, 0.45, 0.45) + // dc.SetLineWidth(2) + // case "tertiary": + // dc.SetRGB(0.7, 0.4, 0.4) + // dc.SetLineWidth(1.5) + // case "unclassified": + // dc.SetRGB(0.65, 0.35, 0.35) + // dc.SetLineWidth(1.5) + // case "residential": + // dc.SetRGB(0.6, 0.3, 0.3) + // dc.SetLineWidth(1.5) + // } + // } else { + dc.SetLineWidth(1) + //} + + previousPoint := way.Points[i-1] + currentPixel := pointToPixels(point, bbox, S) + previousPixel := pointToPixels(previousPoint, bbox, S) + dc.DrawLine(previousPixel.X, previousPixel.Y, currentPixel.X, currentPixel.Y) + dc.Stroke() + } + } + } + + w.Header().Set("Content-Type", "image/png") + if err := dc.EncodePNG(w); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/renderer/experimental-renderer_test.go b/renderer/experimental-renderer_test.go new file mode 100644 index 0000000..5c5fb8f --- /dev/null +++ b/renderer/experimental-renderer_test.go @@ -0,0 +1,182 @@ +//go:build !windows +// +build !windows + +package renderer + +import ( + "bytes" + "fmt" + "io/ioutil" + "log" + "net/http/httptest" + "os" + "syscall" + "testing" + "time" +) + +// Test to check bounding box +func TestBoundingBox(t *testing.T) { + // Test bounding box + p := Point{10.068140300000001, 53.6577386} + tile := Tile{ + X: 138403, + Y: 84591, + Z: 18, + } + bbox := getBoundingBox(tile) + if !bbox.contains(p) { + t.Errorf("Point %v is not in bounding box %v", p, bbox) + } +} + +// How to debug +// go test -benchtime 15s -bench=BenchmarkServe -cpuprofile cpu.out +// go tool pprof cpu.out +// svg + +// 26440844f98323a1ea611acd3be00196a7b2a48d 2023-06-27 +// +// 752985932 ns/op +// +// 643b79ef31ade931afa5037381eeecd8a2a19d19 +// +// 54120852 ns/op +// +// b82ccbeef7bebe3647783ea9b3ed80638d5785cd +// +// 8390724 ns/op +// 5443820 ns/op +// +// Full tile +// 3484142532 ns/op +// cec1fd4962a9fd01956f827a7ca74d98560bb6a1 +// +// 293297990 ns/op +// 88056 ns/op +// +// c03af9bccb2499f3f0291c8e8aca22f141f06600 +// +// 77898 ns/op +// +// ef29875363c610d536be31e603921c71ef698468 +// +// 95696 ns/op +func BenchmarkServeEmptyTile(b *testing.B) { + b.StopTimer() + pathTile := "/tile/11/1086/664.png" + tempFile, err := ioutil.TempFile("", "example") + if err != nil { + fmt.Println("Cannot create temp file:", err) + os.Exit(1) + } + defer os.Remove(tempFile.Name()) + data, err := LoadData("/home/nokadmin/projects/go_tile/mock_data/test.osm.pbf", 15, tempFile) + if err != nil { + b.Error(err) + } + tempFileName := tempFile.Name() + tempFile.Close() + // Memory-map the file + mmapData, mmapFile, err := Mmap(tempFileName) + if err != nil { + log.Fatalf("There was an error memory-mapping temp file: %v", err) + } + defer syscall.Munmap(*mmapData) + defer mmapFile.Close() + b.StartTimer() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest("GET", pathTile, bytes.NewReader([]byte{})) + resp := httptest.ResponseRecorder{} + HandleRenderRequest(&resp, req, time.Second, data, 15, mmapData) + } +} + +// b82ccbeef7bebe3647783ea9b3ed80638d5785cd +// +// 2579786011 ns/op +// 2532791281 ns/op +// +// cec1fd4962a9fd01956f827a7ca74d98560bb6a1 +// 18045510356 ns/op +// 13405872770 ns/op +// +// 4064247136 ns/op +// +// c03af9bccb2499f3f0291c8e8aca22f141f06600 +// +// 2456439762 ns/op +// 2536369085 ns/op +func BenchmarkServeFullTile(b *testing.B) { + b.StopTimer() + pathTile := "/tile/11/1081/661.png" + tempFile, err := ioutil.TempFile("", "example") + if err != nil { + fmt.Println("Cannot create temp file:", err) + os.Exit(1) + } + defer os.Remove(tempFile.Name()) + data, err := LoadData("/home/nokadmin/projects/go_tile/mock_data/test.osm.pbf", 15, tempFile) + if err != nil { + b.Error(err) + } + tempFileName := tempFile.Name() + tempFile.Close() + // Memory-map the file + mmapData, mmapFile, err := Mmap(tempFileName) + if err != nil { + log.Fatalf("There was an error memory-mapping temp file: %v", err) + } + defer syscall.Munmap(*mmapData) + defer mmapFile.Close() + b.StartTimer() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest("GET", pathTile, bytes.NewReader([]byte{})) + resp := httptest.ResponseRecorder{} + HandleRenderRequest(&resp, req, time.Second, data, 15, mmapData) + } +} + +// b82ccbeef7bebe3647783ea9b3ed80638d5785cd +// +// 8440346178 ns/op +// 90810744 ns/op +// +// cec1fd4962a9fd01956f827a7ca74d98560bb6a1 +// +// 41576416793 ns/op +// 7996205074 ns/op +// +// c03af9bccb2499f3f0291c8e8aca22f141f06600 +// +// 4617123956 ns/op +// 101012328 ns/op +func BenchmarkServeFullTileZ3(b *testing.B) { + b.StopTimer() + pathTile := "/tile/3/4/2.png" + tempFile, err := ioutil.TempFile("", "example") + if err != nil { + fmt.Println("Cannot create temp file:", err) + os.Exit(1) + } + defer os.Remove(tempFile.Name()) + data, err := LoadData("/home/nokadmin/projects/go_tile/mock_data/test.osm.pbf", 15, tempFile) + if err != nil { + b.Error(err) + } + tempFileName := tempFile.Name() + tempFile.Close() + // Memory-map the file + mmapData, mmapFile, err := Mmap(tempFileName) + if err != nil { + log.Fatalf("There was an error memory-mapping temp file: %v", err) + } + defer syscall.Munmap(*mmapData) + defer mmapFile.Close() + b.StartTimer() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest("GET", pathTile, bytes.NewReader([]byte{})) + resp := httptest.ResponseRecorder{} + HandleRenderRequest(&resp, req, time.Second, data, 15, mmapData) + } +} diff --git a/renderer/file.go b/renderer/file.go new file mode 100644 index 0000000..620ef08 --- /dev/null +++ b/renderer/file.go @@ -0,0 +1,88 @@ +package renderer + +import ( + "bytes" + "encoding/binary" + "os" +) + +func WriteMapObject(file *os.File, mo MapObject) (int64, error) { + // Buffer to store binary representation + buf := new(bytes.Buffer) + + // Write BoundingBox + err := binary.Write(buf, binary.LittleEndian, mo.BoundingBox.Min.Lat) + if err != nil { + return 0, err + } + err = binary.Write(buf, binary.LittleEndian, mo.BoundingBox.Min.Lon) + if err != nil { + return 0, err + } + err = binary.Write(buf, binary.LittleEndian, mo.BoundingBox.Max.Lat) + if err != nil { + return 0, err + } + err = binary.Write(buf, binary.LittleEndian, mo.BoundingBox.Max.Lon) + if err != nil { + return 0, err + } + + // Write length of Points slice + err = binary.Write(buf, binary.LittleEndian, int64(len(mo.Points))) + if err != nil { + return 0, err + } + + // Write Points + for _, p := range mo.Points { + err = binary.Write(buf, binary.LittleEndian, p.Lat) + if err != nil { + return 0, err + } + err = binary.Write(buf, binary.LittleEndian, p.Lon) + if err != nil { + return 0, err + } + } + + // Write to file + n, err := file.Write(buf.Bytes()) + return int64(n), err +} + +func ReadMapObject(mmapData *[]byte, offset int64, mo *MapObject) error { + // Go to the correct offset + + // Create a bytes reader for the buffer + reader := bytes.NewReader((*mmapData)[offset : offset+40]) + + // Read BoundingBox and length from the buffer + binary.Read(reader, binary.LittleEndian, &mo.BoundingBox.Min.Lat) + binary.Read(reader, binary.LittleEndian, &mo.BoundingBox.Min.Lon) + binary.Read(reader, binary.LittleEndian, &mo.BoundingBox.Max.Lat) + binary.Read(reader, binary.LittleEndian, &mo.BoundingBox.Max.Lon) + + var lenPoints int64 + binary.Read(reader, binary.LittleEndian, &lenPoints) + + // Create a bytes reader for the buffer + reader = bytes.NewReader((*mmapData)[offset+40 : offset+40+lenPoints*16]) + + // Ensure Points slice is big enough + if cap(mo.Points) < int(lenPoints) { + mo.Points = make([]Point, lenPoints) + } else { + mo.Points = mo.Points[:lenPoints] + } + + // Read Points from the buffer + for i := int64(0); i < lenPoints; i++ { + var p Point + binary.Read(reader, binary.LittleEndian, &p.Lat) + binary.Read(reader, binary.LittleEndian, &p.Lon) + mo.Points[i] = p + } + + return nil +} diff --git a/renderer/projector.go b/renderer/projector.go new file mode 100644 index 0000000..a39540d --- /dev/null +++ b/renderer/projector.go @@ -0,0 +1,61 @@ +package renderer + +import ( + "math" + + "github.com/paulmach/osm" +) + +func getBoundingBox(tile Tile) BoundingBox { + n := math.Pow(2.0, float64(tile.Z)) + lonMin := float64(tile.X)/n*360.0 - 180.0 + latMin := math.Atan(math.Sinh(math.Pi*(1-2*float64(tile.Y)/n))) * 180.0 / math.Pi + lonMax := float64(tile.X+1)/n*360.0 - 180.0 + latMax := math.Atan(math.Sinh(math.Pi*(1-2*float64(tile.Y+1)/n))) * 180.0 / math.Pi + pointMin := Point{math.Min(lonMin, lonMax), math.Min(latMin, latMax)} + pointMax := Point{math.Max(lonMin, lonMax), math.Max(latMin, latMax)} + return BoundingBox{pointMin, pointMax} +} + +func getBoundingBoxFromWay(way *osm.Way) BoundingBox { + var lonMin float64 = 200.0 + var latMin float64 = 200.0 + var lonMax float64 = -200.0 + var latMax float64 = -200.0 + for _, node := range way.Nodes { + lonMin = math.Min(lonMin, node.Lon) + latMin = math.Min(latMin, node.Lat) + lonMax = math.Max(lonMax, node.Lon) + latMax = math.Max(latMax, node.Lat) + } + pointMin := Point{lonMin, latMin} + pointMax := Point{lonMax, latMax} + return BoundingBox{pointMin, pointMax} + +} + +// Given a latitude, longitude and zoom level, return the tile coordinates +func deg2num(lat_deg, lon_deg float64, zoom uint32) (x, y uint32) { + lat_rad := math.Pi * lat_deg / 180.0 + n := math.Pow(2.0, float64(zoom)) + x = uint32(math.Floor((lon_deg + 180.0) / 360.0 * n)) + y = uint32(math.Floor((1.0 - math.Log(math.Tan(lat_rad)+(1/math.Cos(lat_rad)))/math.Pi) / 2.0 * n)) + return +} + +func getTilesForBoundingBox(bbox BoundingBox, minZ, maxZ uint32) []Tile { + var tiles []Tile + + for z := minZ; z <= maxZ; z++ { + minX, minY := deg2num(bbox.Max.Lat, bbox.Min.Lon, z) + maxX, maxY := deg2num(bbox.Min.Lat, bbox.Max.Lon, z) + + for x := minX; x <= maxX; x++ { + for y := minY; y <= maxY; y++ { + tiles = append(tiles, Tile{X: x, Y: y, Z: z}) + } + } + } + + return tiles +} diff --git a/renderer/projector_test.go b/renderer/projector_test.go new file mode 100644 index 0000000..a2e4ebd --- /dev/null +++ b/renderer/projector_test.go @@ -0,0 +1,51 @@ +package renderer + +import "testing" + +func TestNum2Deg(t *testing.T) { + // The center of Hamburg + tile := Tile{ + X: 138346, + Y: 84715, + Z: 18, + } + bbox := getBoundingBox(tile) + test_point := bbox.center() + x, y := deg2num(test_point.Lat, test_point.Lon, 18) + if x != tile.X { + t.Errorf("X should be %v, but is %v", tile.X, x) + } + if y != tile.Y { + t.Errorf("Y should be %v, but is %v", tile.Y, y) + } +} + +func TestGetTilesForBoundingBox(t *testing.T) { + bbox := BoundingBox{ + Min: Point{ + Lat: 53.557078, + Lon: 9.989095, + }, + Max: Point{ + Lat: 53.557078, + Lon: 9.989095, + }, + } + tiles := getTilesForBoundingBox(bbox, 18, 18) + if len(tiles) != 1 { + t.Errorf("Should be 1 tile, but is %v", len(tiles)) + } + if tiles[0].X != 138345 { + t.Errorf("X should be 138346, but is %v", tiles[0].X) + } + if tiles[0].Y != 84715 { + t.Errorf("Y should be 84715, but is %v", tiles[0].Y) + } + if tiles[0].Z != 18 { + t.Errorf("Z should be 18, but is %v", tiles[0].Z) + } + tiles = getTilesForBoundingBox(bbox, 1, 17) + if len(tiles) != 17 { + t.Errorf("Should be 17 tiles, but is %v", len(tiles)) + } +} diff --git a/renderer/types.go b/renderer/types.go new file mode 100644 index 0000000..4d17687 --- /dev/null +++ b/renderer/types.go @@ -0,0 +1,87 @@ +package renderer + +import ( + "math" +) + +type Tile struct { + X, Y, Z uint32 +} + +// when all possible tiles including all smaller zoom levels are stored in a quadtree, this function will be used to get the index of a tile +func (tile Tile) index() uint64 { + // Calculate the total number of tiles for all zoom levels from 0 to tile.Z-1 + total := uint64(0) + for z := uint32(0); z < tile.Z; z++ { + total += uint64(math.Pow(4, float64(z))) + } + + // Calculate the position of the tile within its zoom level + levelPos := tile.Y*uint32(math.Pow(2, float64(tile.Z))) + tile.X + + return total + uint64(levelPos) +} + +// Returns the OSM tile one zoom level above that contains the given point +func (tile Tile) getParent() Tile { + return Tile{tile.X / 2, tile.Y / 2, tile.Z - 1} +} + +type Point struct { + Lon, Lat float64 +} + +type BoundingBox struct { + Min, Max Point +} + +type Pixel struct { + X, Y float64 +} + +// member function checks if a point is inside a bounding box +func (bbox BoundingBox) contains(point Point) bool { + return point.Lat >= bbox.Min.Lat && point.Lat <= bbox.Max.Lat && point.Lon >= bbox.Min.Lon && point.Lon <= bbox.Max.Lon +} + +func (bbox BoundingBox) overlaps(other BoundingBox) bool { + return bbox.Min.Lat <= other.Max.Lat && bbox.Max.Lat >= other.Min.Lat && bbox.Min.Lon <= other.Max.Lon && bbox.Max.Lon >= other.Min.Lon +} + +func (bbox BoundingBox) center() Point { + return Point{(bbox.Min.Lon + bbox.Max.Lon) / 2, (bbox.Min.Lat + bbox.Max.Lat) / 2} +} + +// Architecture: +// We have tiles. Each tile has a bounding box. +// Each tile has a list of map objects. +// Each map object has a bounding box. +// Each map object has a list of points. +// Each point has a longitude and a latitude. +// Each tile may have 4 children and 1 parent. +// Each tile has x, y, and z coordinates. +// Map objects are stored at the tile that contains their bounding box. +// As Tiles are stored on disk, we cannot reference them through pointers but by their index position. +// There is a quadtree for every zoom level. +// The quadtree stops dividing when there are 64 or less map objects in a tile. +type TileData struct { + X, Y, Z uint32 + Children [4]uint64 +} + +type MapObjectOffset uint64 + +type Data struct { + // ChatGPT has the idea to use a map of tiles as a sparse array. Not sure what the performance hit of that is. + // Then I could skip the whole fancy part of continuous memory layout and just have a map[Tile][] map. + // TODO: replace Tile with index + Tiles map[uint64]*[]MapObjectOffset + Filepath string + MaxPoints int +} + +type MapObject struct { + //The bounding box of the map object + BoundingBox BoundingBox + Points []Point +} diff --git a/renderer/types_test.go b/renderer/types_test.go new file mode 100644 index 0000000..34328a0 --- /dev/null +++ b/renderer/types_test.go @@ -0,0 +1,21 @@ +package renderer + +import "testing" + +func TestTileGetParent(t *testing.T) { + tile := Tile{ + X: 276643, + Y: 169357, + Z: 19, + } + parent := tile.getParent() + if parent.X != 138321 { + t.Errorf("X should be 138321, but is %v", parent.X) + } + if parent.Y != 84678 { + t.Errorf("Y should be 84678, but is %v", parent.Y) + } + if parent.Z != 18 { + t.Errorf("Z should be 18, but is %v", parent.Z) + } +} diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..feadd8a --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,33 @@ +package utils + +import ( + "errors" + "regexp" + "strconv" +) + +var _matcher = regexp.MustCompile(`^/tile/([0-9]+)/([0-9]+)/([0-9]+).(png|webp)$`) + +func ParsePath(path string) (z, x, y uint32, ext string, err error) { + matches := _matcher.FindStringSubmatch(path) + if len(matches) != 5 { + return 0, 0, 0, "", errors.New("could not match path") + } + zInt, err := strconv.Atoi(matches[1]) + if err != nil { + return + } + xInt, err := strconv.Atoi(matches[2]) + if err != nil { + return + } + yInt, err := strconv.Atoi(matches[3]) + if err != nil { + return + } + ext = matches[4] + z = uint32(zInt) + x = uint32(xInt) + y = uint32(yInt) + return +} diff --git a/utils/utils_test.go b/utils/utils_test.go new file mode 100644 index 0000000..ab6798a --- /dev/null +++ b/utils/utils_test.go @@ -0,0 +1,34 @@ +package utils + +import "testing" + +func TestParse(t *testing.T) { + z, x, y, ext, err := ParsePath("/tile/4/3/2.webp") + if err != nil { + t.Error(err) + } + if z != 4 || x != 3 || y != 2 || ext != "webp" { + t.Fail() + } +} + +func TestParseError(t *testing.T) { + invalid_paths := []string{ + "/tile/4/3/2", + "/tile/4/3/2.jpg", + "/tile/4/3/2.png/", + "/tile/4/3/2.png/3", + "/tile/4/3/2.png/3/4", + "/tile/-1/3/2.png", + "/tile/1.5/3/2.png", + "/tile/10000/3.5/2.png", + "/tile/100000000000000000000000000000000000000000000000000000000000000000/3/2.5.png", + "/tile/abc/3/2.png", + } + for _, path := range invalid_paths { + _, _, _, _, err := ParsePath(path) + if err == nil { + t.Errorf("expected error for path %s", path) + } + } +}