Files
2025-08-27 11:12:15 +02:00

806 lines
24 KiB
Go

// The main package is the entry point for the application.
package main
// Import necessary libraries.
import (
"bytes"
"context"
"embed" // Used for embedding files into the binary.
"encoding/json"
"flag"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"io"
"io/fs"
"log"
"math"
"math/rand"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/gorilla/websocket" // WebSocket library for real-time communication.
)
//go:embed templates/index.html
var indexHTML []byte // Embeds the index.html file into the binary.
//go:embed config/map_sources.json
var mapSourcesJSON []byte // Embeds the map_sources.json file into the binary.
//go:embed static/*
var staticFiles embed.FS
// upgrader is used to upgrade HTTP connections to WebSocket connections.
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024, // Size of the read buffer.
WriteBufferSize: 1024, // Size of the write buffer.
}
// Global variables used throughout the application.
var (
mapSources map[string]string // Stores the available map sources.
downloadCancel context.CancelFunc // Function to cancel an ongoing download.
downloading bool // Flag to indicate if a download is in progress.
downloadingMutex sync.Mutex // Mutex to protect access to the downloading flag.
cacheDir *string
maxWorkers *int
rateLimit *int
maxRetries *int
)
// Tile represents a single map tile with X, Y coordinates and zoom level Z.
type Tile struct {
X, Y, Z uint32
}
// BoundingBox represents a geographical area with North, South, East, and West boundaries.
type BoundingBox struct {
North, South, East, West float64
}
// LatLng represents a geographical point with latitude and longitude.
type LatLng struct {
Lat float64 `json:"lat"` // Latitude
Lng float64 `json:"lng"` // Longitude
}
// DownloadRequest represents a request to download map tiles for a specific area.
type DownloadRequest struct {
Polygons [][]LatLng `json:"polygons"` // The polygons defining the download area.
MinZoom int `json:"min_zoom"` // The minimum zoom level to download.
MaxZoom int `json:"max_zoom"` // The maximum zoom level to download.
MapStyle string `json:"map_style"` // The URL of the map tile server.
ConvertTo8Bit bool `json:"convert_to_8bit"` // Whether to convert images to 8-bit PNG.
}
// WorldDownloadRequest represents a request to download map tiles for the entire world.
type WorldDownloadRequest struct {
MapStyle string `json:"map_style"` // The URL of the map tile server.
ConvertTo8Bit bool `json:"convert_to_8bit"` // Whether to convert images to 8-bit PNG.
}
// WSMessage represents a WebSocket message with a type and data.
type WSMessage struct {
Type string `json:"type"` // The type of the message (e.g., "start_download").
Data interface{} `json:"data"` // The data associated with the message.
}
// main is the entry point of the application.
func main() {
// Command line flags
port := flag.Int("port", 8080, "Port number for the server")
cacheDir = flag.String("maps-directory", "maps", "Directory for storing map tiles. This is where the downloaded tiles will be saved.")
maxWorkers = flag.Int("max-workers", 10, "Number of concurrent download workers")
rateLimit = flag.Int("rate-limit", 50, "Maximum number of tiles to download per second")
maxRetries = flag.Int("max-retries", 3, "Maximum number of retries for downloading a tile")
help := flag.Bool("help", false, "Show help message")
flag.Parse()
if *help {
flag.Usage()
return
}
// Create cache directory if it doesn't exist.
if err := os.MkdirAll(*cacheDir, 0755); err != nil {
log.Fatalf("Failed to create cache directory: %v", err)
}
// Load map sources from the embedded JSON file.
if err := json.Unmarshal(mapSourcesJSON, &mapSources); err != nil {
log.Fatalf("Failed to load map sources: %v", err)
}
// Register HTTP handlers for different routes.
http.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = "/static/favicon.ico"
http.FileServer(http.FS(staticFiles)).ServeHTTP(w, r)
})
http.HandleFunc("/", serveHome)
http.HandleFunc("/get_map_sources", getMapSources)
http.HandleFunc("/ws", wsHandler)
http.HandleFunc("/tiles/", serveTile)
http.HandleFunc("/get_cached_tiles/", getCachedTiles)
staticFS, err := fs.Sub(staticFiles, "static")
if err != nil {
log.Fatal(err)
}
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
// Start the HTTP server on port 8080.
addr := fmt.Sprintf(":%d", *port)
log.Printf("Starting server on %s", addr)
if err := http.ListenAndServe(addr, nil); err != nil {
log.Fatalf("Error starting server: %v", err)
}
}
// serveHome serves the main HTML page.
func serveHome(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
if _, err := w.Write(indexHTML); err != nil {
log.Printf("Could not write response: %v", err)
}
}
// getMapSources serves the available map sources as JSON.
func getMapSources(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(mapSourcesJSON); err != nil {
log.Printf("Could not write response: %v", err)
}
}
// wsHandler handles WebSocket connections.
func wsHandler(w http.ResponseWriter, r *http.Request) {
// Upgrade the HTTP connection to a WebSocket connection.
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
defer func() {
if err := conn.Close(); err != nil {
log.Printf("Could not close websocket connection: %v", err)
}
}()
// Loop to read messages from the WebSocket connection.
for {
messageType, p, err := conn.ReadMessage()
if err != nil {
log.Println(err)
return
}
if messageType == websocket.TextMessage {
var msg WSMessage
if err := json.Unmarshal(p, &msg); err != nil {
log.Println("Error unmarshalling message:", err)
continue
}
// Handle different message types.
switch msg.Type {
case "start_download":
var req DownloadRequest
b, _ := json.Marshal(msg.Data)
if err := json.Unmarshal(b, &req); err != nil {
sendError(conn, "Invalid download request")
continue
}
go handleStartDownload(conn, req)
case "start_world_download":
var req WorldDownloadRequest
b, _ := json.Marshal(msg.Data)
if err := json.Unmarshal(b, &req); err != nil {
sendError(conn, "Invalid world download request")
continue
}
go handleStartWorldDownload(conn, req)
case "cancel_download":
handleCancelDownload(conn)
}
}
}
}
// handleStartDownload starts a new download process for a defined area.
func handleStartDownload(conn *websocket.Conn, req DownloadRequest) {
// Lock the mutex to ensure only one download runs at a time.
downloadingMutex.Lock()
if downloading {
sendError(conn, "Another download is already in progress.")
downloadingMutex.Unlock()
return
}
downloading = true
downloadingMutex.Unlock()
// Defer setting the downloading flag to false.
defer func() {
downloadingMutex.Lock()
downloading = false
downloadingMutex.Unlock()
}()
log.Printf("Starting download for area: %v, zoom: %d-%d, map style: %s", req.Polygons, req.MinZoom, req.MaxZoom, req.MapStyle)
// Create a new context to allow for cancellation.
var ctx context.Context
ctx, downloadCancel = context.WithCancel(context.Background())
// Get the style name and cache directory.
styleName := getStyleName(req.MapStyle)
styleCacheDir := getStyleCacheDir(styleName)
// Validate the zoom range.
if req.MinZoom < 0 || req.MaxZoom > 19 || req.MinZoom > req.MaxZoom {
sendError(conn, "Invalid zoom range (must be 0-19, min <= max)")
return
}
// Validate the polygons.
if len(req.Polygons) == 0 {
sendError(conn, "No polygons provided")
return
}
// Get the list of tiles to download.
tilesToDownload := getTilesForPolygons(req.Polygons, req.MinZoom, req.MaxZoom)
// Start the tile download process.
downloadTiles(ctx, conn, tilesToDownload, req.MapStyle, styleCacheDir, req.ConvertTo8Bit)
// If the download was not cancelled
if ctx.Err() == nil {
sendMessage(conn, "download_complete", nil)
}
}
// handleStartWorldDownload starts a new download process for the entire world.
func handleStartWorldDownload(conn *websocket.Conn, req WorldDownloadRequest) {
// Lock the mutex to ensure only one download runs at a time.
downloadingMutex.Lock()
if downloading {
sendError(conn, "Another download is already in progress.")
downloadingMutex.Unlock()
return
}
downloading = true
downloadingMutex.Unlock()
// Defer setting the downloading flag to false.
defer func() {
downloadingMutex.Lock()
downloading = false
downloadingMutex.Unlock()
}()
log.Printf("Starting world download, map style: %s", req.MapStyle)
// Create a new context to allow for cancellation.
var ctx context.Context
ctx, downloadCancel = context.WithCancel(context.Background())
// Get the style name and cache directory.
styleName := getStyleName(req.MapStyle)
styleCacheDir := getStyleCacheDir(styleName)
// Get the list of tiles to download for the world.
tilesToDownload := getWorldTiles()
// Start the tile download process.
downloadTiles(ctx, conn, tilesToDownload, req.MapStyle, styleCacheDir, req.ConvertTo8Bit)
// If the download was not cancelled
if ctx.Err() == nil {
sendMessage(conn, "download_complete", nil)
}
}
// handleCancelDownload cancels an ongoing download.
func handleCancelDownload(conn *websocket.Conn) {
if downloadCancel != nil {
downloadCancel()
log.Printf("Download cancelled by user")
sendMessage(conn, "download_cancelled", nil)
}
}
// downloadTiles downloads a list of tiles concurrently.
func downloadTiles(ctx context.Context, conn *websocket.Conn, tilesToDownload []Tile, mapStyle, styleCacheDir string, convertTo8Bit bool) {
// Create a channel for WebSocket messages.
msgChan := make(chan WSMessage)
var writerWg sync.WaitGroup
writerWg.Add(1)
// Start a goroutine to send messages from the channel to the WebSocket connection.
go func() {
defer writerWg.Done()
for msg := range msgChan {
if err := conn.WriteJSON(msg); err != nil {
log.Println("Error writing JSON to websocket:", err)
return
}
}
}()
// Send a message indicating the download has started.
msgChan <- WSMessage{Type: "download_started", Data: map[string]int{"total_tiles": len(tilesToDownload)}}
// Use a WaitGroup to wait for all download goroutines to finish.
var downloadWg sync.WaitGroup
tileChan := make(chan Tile)
// Start the download workers.
for i := 0; i < *maxWorkers; i++ {
downloadWg.Add(1)
go func() {
defer downloadWg.Done()
for tile := range tileChan {
select {
case <-ctx.Done(): // Check if the download has been cancelled.
return
default:
downloadTile(ctx, msgChan, tile, mapStyle, styleCacheDir, convertTo8Bit, *maxRetries)
}
}
}()
}
// Rate limit the download of tiles.
ticker := time.NewTicker(time.Second / time.Duration(*rateLimit))
defer ticker.Stop()
DownloadLoop:
for _, tile := range tilesToDownload {
select {
case <-ctx.Done():
break DownloadLoop
case <-ticker.C:
tileChan <- tile
}
}
close(tileChan)
// Wait for all downloads to complete.
downloadWg.Wait()
close(msgChan)
writerWg.Wait()
// If the download was not cancelled, send a completion message.
if ctx.Err() == nil {
log.Printf("Download finished successfully")
sendMessage(conn, "tiles_downloaded", nil)
} else {
log.Printf("Download failed or was cancelled")
}
}
// downloadTile downloads a single map tile.
func downloadTile(ctx context.Context, msgChan chan<- WSMessage, tile Tile, mapStyle, styleCacheDir string, convertTo8Bit bool, maxRetries int) {
// Construct the path to the tile file.
tileDir := filepath.Join(styleCacheDir, fmt.Sprintf("%d/%d", tile.Z, tile.X))
tilePath := filepath.Join(tileDir, fmt.Sprintf("%d.png", tile.Y))
// Check if the tile already exists in the cache.
if _, err := os.Stat(tilePath); err == nil {
bounds := tileBounds(tile)
msgChan <- WSMessage{Type: "tile_skipped", Data: map[string]float64{
"west": bounds.West,
"south": bounds.South,
"east": bounds.East,
"north": bounds.North,
}}
return
}
// Construct the URL for the tile.
subdomain := []string{"a", "b", "c"}[rand.Intn(3)]
url := strings.ReplaceAll(mapStyle, "{s}", subdomain)
url = strings.ReplaceAll(url, "{z}", fmt.Sprintf("%d", tile.Z))
url = strings.ReplaceAll(url, "{x}", fmt.Sprintf("%d", tile.X))
url = strings.ReplaceAll(url, "{y}", fmt.Sprintf("%d", tile.Y))
var err error
for attempt := 0; attempt < maxRetries; attempt++ {
select {
case <-ctx.Done(): // Check for cancellation.
return
default:
}
var req *http.Request
req, err = http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
log.Printf("Error creating request for tile %v: %v. Retrying...", tile, err)
time.Sleep(time.Second * time.Duration(math.Pow(2, float64(attempt))))
continue
}
req.Header.Set("User-Agent", "MapTileDownloader/1.0 (Go)")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("Error downloading tile %v: %v. Retrying...", tile, err)
time.Sleep(time.Second * time.Duration(math.Pow(2, float64(attempt))))
continue
}
if resp.StatusCode != http.StatusOK {
if err := resp.Body.Close(); err != nil {
log.Printf("Could not close response body: %v", err)
}
log.Printf("Unexpected status code %d for tile %v. Retrying...", resp.StatusCode, tile)
time.Sleep(time.Second * time.Duration(math.Pow(2, float64(attempt))))
continue
}
body, err := io.ReadAll(resp.Body)
if err := resp.Body.Close(); err != nil {
log.Printf("Could not close response body: %v", err)
}
if err != nil {
log.Printf("Error reading tile body for tile %v: %v. Retrying...", tile, err)
time.Sleep(time.Second * time.Duration(math.Pow(2, float64(attempt))))
continue
}
if err := os.MkdirAll(tileDir, 0755); err != nil {
log.Printf("Error creating tile directory for tile %v: %v", tile, err)
return // No point in retrying if we can't create the directory
}
// Convert the image to 8-bit PNG if requested.
if convertTo8Bit {
img, _, err := image.Decode(bytes.NewReader(body))
if err == nil {
paletted := image.NewPaletted(img.Bounds(), color.Palette{})
draw.Draw(paletted, paletted.Rect, img, img.Bounds().Min, draw.Src)
var buf bytes.Buffer
if err := png.Encode(&buf, paletted); err == nil {
body = buf.Bytes()
}
}
}
if err := os.WriteFile(tilePath, body, 0644); err != nil {
log.Printf("Error writing tile %v: %v", tile, err)
return // No point in retrying if we can't write the file
}
bounds := tileBounds(tile)
msgChan <- WSMessage{Type: "tile_downloaded", Data: map[string]float64{
"west": bounds.West,
"south": bounds.South,
"east": bounds.East,
"north": bounds.North,
}}
return // Success!
}
// If all retries fail, send a failure message.
log.Printf("Failed to download tile %v after %d attempts.", tile, maxRetries)
msgChan <- WSMessage{Type: "tile_failed", Data: map[string]string{"tile": fmt.Sprintf("%d/%d/%d", tile.Z, tile.X, tile.Y)}}
}
// getTilesForPolygons calculates the tiles needed to cover the given polygons.
func getTilesForPolygons(polygonsData [][]LatLng, minZoom, maxZoom int) []Tile {
var allTiles []Tile
tileMap := make(map[Tile]bool)
for _, polyData := range polygonsData {
if len(polyData) < 3 {
continue
}
minLat, minLon := 90.0, 180.0
maxLat, maxLon := -90.0, -180.0
for _, p := range polyData {
if p.Lat < minLat {
minLat = p.Lat
}
if p.Lat > maxLat {
maxLat = p.Lat
}
if p.Lng < minLon {
minLon = p.Lng
}
if p.Lng > maxLon {
maxLon = p.Lng
}
}
for z := minZoom; z <= maxZoom; z++ {
tlx, tly := latLonToTile(maxLat, minLon, uint32(z))
brx, bry := latLonToTile(minLat, maxLon, uint32(z))
for x := tlx; x <= brx; x++ {
for y := tly; y <= bry; y++ {
tile := Tile{X: x, Y: y, Z: uint32(z)}
if _, exists := tileMap[tile]; exists {
continue
}
bounds := tileBounds(tile)
// Check if the tile is completely inside the polygon
if polygonContains(polyData, LatLng{Lat: bounds.North, Lng: bounds.West}) &&
polygonContains(polyData, LatLng{Lat: bounds.North, Lng: bounds.East}) &&
polygonContains(polyData, LatLng{Lat: bounds.South, Lng: bounds.West}) &&
polygonContains(polyData, LatLng{Lat: bounds.South, Lng: bounds.East}) {
allTiles = append(allTiles, tile)
tileMap[tile] = true
continue
}
// Check if the polygon is completely inside the tile
polyInTile := true
for _, p := range polyData {
if !tileContains(bounds, p) {
polyInTile = false
break
}
}
if polyInTile {
allTiles = append(allTiles, tile)
tileMap[tile] = true
continue
}
// Check for intersection
if polygonIntersects(polyData, bounds) {
allTiles = append(allTiles, tile)
tileMap[tile] = true
}
}
}
}
}
return allTiles
}
// tileContains checks if a tile contains a point.
func tileContains(bounds BoundingBox, point LatLng) bool {
return point.Lat <= bounds.North && point.Lat >= bounds.South && point.Lng >= bounds.West && point.Lng <= bounds.East
}
// polygonIntersects checks if a polygon intersects with a tile.
func polygonIntersects(poly []LatLng, bounds BoundingBox) bool {
// Check if any of the polygon's vertices are inside the tile
for _, p := range poly {
if tileContains(bounds, p) {
return true
}
}
// Check if any of the tile's corners are inside the polygon
if polygonContains(poly, LatLng{Lat: bounds.North, Lng: bounds.West}) ||
polygonContains(poly, LatLng{Lat: bounds.North, Lng: bounds.East}) ||
polygonContains(poly, LatLng{Lat: bounds.South, Lng: bounds.West}) ||
polygonContains(poly, LatLng{Lat: bounds.South, Lng: bounds.East}) {
return true
}
// Check if any of the polygon's edges intersect with the tile's edges
for i := 0; i < len(poly); i++ {
p1 := poly[i]
p2 := poly[(i+1)%len(poly)]
if lineIntersects(p1, p2, LatLng{Lat: bounds.North, Lng: bounds.West}, LatLng{Lat: bounds.North, Lng: bounds.East}) ||
lineIntersects(p1, p2, LatLng{Lat: bounds.North, Lng: bounds.East}, LatLng{Lat: bounds.South, Lng: bounds.East}) ||
lineIntersects(p1, p2, LatLng{Lat: bounds.South, Lng: bounds.East}, LatLng{Lat: bounds.South, Lng: bounds.West}) ||
lineIntersects(p1, p2, LatLng{Lat: bounds.South, Lng: bounds.West}, LatLng{Lat: bounds.North, Lng: bounds.West}) {
return true
}
}
return false
}
// lineIntersects checks if two line segments intersect.
func lineIntersects(p1, q1, p2, q2 LatLng) bool {
o1 := orientation(p1, q1, p2)
o2 := orientation(p1, q1, q2)
o3 := orientation(p2, q2, p1)
o4 := orientation(p2, q2, q1)
if o1 != o2 && o3 != o4 {
return true
}
// Special Cases for colinear points
if o1 == 0 && onSegment(p1, p2, q1) {
return true
}
if o2 == 0 && onSegment(p1, q2, q1) {
return true
}
if o3 == 0 && onSegment(p2, p1, q2) {
return true
}
if o4 == 0 && onSegment(p2, q1, q2) {
return true
}
return false
}
// orientation finds the orientation of the ordered triplet (p, q, r).
func orientation(p, q, r LatLng) int {
val := (q.Lng-p.Lng)*(r.Lat-q.Lat) - (q.Lat-p.Lat)*(r.Lng-q.Lng)
if val == 0 {
return 0 // Collinear
}
if val > 0 {
return 1 // Clockwise
}
return 2 // Counterclockwise
}
// onSegment checks if point q lies on segment pr.
func onSegment(p, q, r LatLng) bool {
if q.Lat <= math.Max(p.Lat, r.Lat) && q.Lat >= math.Min(p.Lat, r.Lat) &&
q.Lng <= math.Max(p.Lng, r.Lng) && q.Lng >= math.Min(p.Lng, r.Lng) {
return true
}
return false
}
// getWorldTiles returns a list of all tiles for the world up to zoom level 7.
func getWorldTiles() []Tile {
var worldTiles []Tile
for z := 0; z <= 7; z++ {
max := 1 << z
for x := 0; x < max; x++ {
for y := 0; y < max; y++ {
worldTiles = append(worldTiles, Tile{X: uint32(x), Y: uint32(y), Z: uint32(z)})
}
}
}
return worldTiles
}
// serveTile serves a single cached tile.
func serveTile(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/tiles/"), "/")
if len(parts) != 4 {
http.NotFound(w, r)
return
}
styleName := parts[0]
z := parts[1]
x := parts[2]
y := strings.TrimSuffix(parts[3], ".png")
tilePath := filepath.Join(*cacheDir, sanitizeStyleName(styleName), z, x, y+".png")
http.ServeFile(w, r, tilePath)
}
// getCachedTiles returns a list of cached tiles for a specific map style.
func getCachedTiles(w http.ResponseWriter, r *http.Request) {
styleName := strings.TrimPrefix(r.URL.Path, "/get_cached_tiles/")
styleCacheDir := getStyleCacheDir(styleName)
var cachedTiles [][3]uint32
err := filepath.Walk(styleCacheDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".png") {
parts := strings.Split(strings.TrimSuffix(path, ".png"), string(filepath.Separator))
if len(parts) >= 4 {
z, zErr := strToUint32(parts[len(parts)-3])
x, xErr := strToUint32(parts[len(parts)-2])
y, yErr := strToUint32(parts[len(parts)-1])
if zErr == nil && xErr == nil && yErr == nil {
cachedTiles = append(cachedTiles, [3]uint32{z, x, y})
}
}
}
return nil
})
if err != nil {
http.Error(w, fmt.Sprintf("Error reading cache: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(cachedTiles); err != nil {
http.Error(w, fmt.Sprintf("Error encoding cached tiles: %v", err), http.StatusInternalServerError)
}
}
// getStyleName returns the name of the map style for a given URL.
func getStyleName(mapStyleURL string) string {
for name, url := range mapSources {
if url == mapStyleURL {
return name
}
}
return "default"
}
// getStyleCacheDir returns the cache directory for a given style name.
func getStyleCacheDir(styleName string) string {
return filepath.Join(*cacheDir, sanitizeStyleName(styleName))
}
// nonAlphanumeric is a regular expression to match any character that is not a letter, number, hyphen, or underscore.
var nonAlphanumeric = regexp.MustCompile(`[^a-zA-Z0-9-_]+`)
// sanitizeStyleName sanitizes the style name to be used as a directory name.
func sanitizeStyleName(styleName string) string {
return nonAlphanumeric.ReplaceAllString(strings.ReplaceAll(styleName, " ", "-"), "")
}
// sendMessage sends a WebSocket message.
func sendMessage(conn *websocket.Conn, msgType string, data interface{}) {
msg := WSMessage{Type: msgType, Data: data}
if err := conn.WriteJSON(msg); err != nil {
log.Println("Error sending message:", err)
}
}
// sendError sends an error message over the WebSocket connection.
func sendError(conn *websocket.Conn, message string) {
sendMessage(conn, "error", map[string]string{"message": message})
}
// strToUint32 converts a string to a uint32.
func strToUint32(s string) (uint32, error) {
var i uint32
_, err := fmt.Sscanf(s, "%d", &i)
return i, err
}
// latLonToTile converts latitude and longitude to tile coordinates.
func latLonToTile(lat, lon float64, zoom uint32) (x, y uint32) {
latRad := lat * math.Pi / 180
n := math.Pow(2, float64(zoom))
x = uint32(n * ((lon + 180) / 360))
y = uint32(n * (1 - (math.Log(math.Tan(latRad)+1/math.Cos(latRad)) / math.Pi)) / 2)
return
}
// tileBounds calculates the geographical bounding box of a tile.
func tileBounds(tile Tile) BoundingBox {
n := math.Pow(2.0, float64(tile.Z))
lonDeg := float64(tile.X)/n*360.0 - 180.0
latRad := math.Atan(math.Sinh(math.Pi * (1 - 2*float64(tile.Y)/n)))
latDeg := latRad * 180.0 / math.Pi
lon2Deg := float64(tile.X+1)/n*360.0 - 180.0
lat2Rad := math.Atan(math.Sinh(math.Pi * (1 - 2*float64(tile.Y+1)/n)))
lat2Deg := lat2Rad * 180.0 / math.Pi
return BoundingBox{
North: latDeg,
South: lat2Deg,
East: lon2Deg,
West: lonDeg,
}
}
// polygonContains checks if a point is inside a polygon using the ray casting algorithm.
func polygonContains(poly []LatLng, point LatLng) bool {
in := false
for i, j := 0, len(poly)-1; i < len(poly); j, i = i, i+1 {
if (poly[i].Lat > point.Lat) != (poly[j].Lat > point.Lat) &&
(point.Lng < (poly[j].Lng-poly[i].Lng)*(point.Lat-poly[i].Lat)/(poly[j].Lat-poly[i].Lat)+poly[i].Lng) {
in = !in
}
}
return in
}