diff --git a/db.go b/db.go index e0498df..3584416 100644 --- a/db.go +++ b/db.go @@ -2,6 +2,7 @@ package main import ( "database/sql" + "math" _ "modernc.org/sqlite" ) @@ -97,3 +98,101 @@ func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band str return results, nil } + +// Route corridor query: find repeaters within corridorKm of a polyline. +func queryRepeatersAlongRoute(db *sql.DB, points [][2]float64, corridorKm float64, band string) ([]Repeater, error) { + if len(points) == 0 { + return []Repeater{}, nil + } + + // Compute bounding box of all route points + corridor padding + minLat, maxLat := points[0][0], points[0][0] + minLng, maxLng := points[0][1], points[0][1] + for _, p := range points { + if p[0] < minLat { + minLat = p[0] + } + if p[0] > maxLat { + maxLat = p[0] + } + if p[1] < minLng { + minLng = p[1] + } + if p[1] > maxLng { + maxLng = p[1] + } + } + latPad := corridorKm / 111.32 + avgLat := (minLat + maxLat) / 2 + lngPad := corridorKm / (111.32 * math.Cos(avgLat*math.Pi/180)) + minLat -= latPad + maxLat += latPad + minLng -= lngPad + maxLng += lngPad + + // Fetch candidates from bounding box + candidates, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band) + if err != nil { + return nil, err + } + + // Filter by distance to route segments + var results []Repeater + for _, r := range candidates { + if minDistToRoute(r.Lat, r.Lng, points) <= corridorKm { + results = append(results, r) + } + } + return results, nil +} + +func minDistToRoute(lat, lng float64, points [][2]float64) float64 { + best := math.Inf(1) + for i := 0; i < len(points)-1; i++ { + d := distToSegmentKm(lat, lng, points[i][0], points[i][1], points[i+1][0], points[i+1][1]) + if d < best { + best = d + } + } + if len(points) == 1 { + best = haversineKm(lat, lng, points[0][0], points[0][1]) + } + return best +} + +// Approximate distance from point P to line segment AB in km. +func distToSegmentKm(pLat, pLng, aLat, aLng, bLat, bLng float64) float64 { + cosLat := math.Cos(pLat * math.Pi / 180) + // Project to approximate planar coordinates (km) + px := (pLng - aLng) * cosLat * 111.32 + py := (pLat - aLat) * 111.32 + bx := (bLng - aLng) * cosLat * 111.32 + by := (bLat - aLat) * 111.32 + + lenSq := bx*bx + by*by + if lenSq == 0 { + return math.Sqrt(px*px + py*py) + } + + t := (px*bx + py*by) / lenSq + if t < 0 { + t = 0 + } + if t > 1 { + t = 1 + } + + dx := px - t*bx + dy := py - t*by + return math.Sqrt(dx*dx + dy*dy) +} + +func haversineKm(lat1, lng1, lat2, lng2 float64) float64 { + const r = 6371.0 + dLat := (lat2 - lat1) * math.Pi / 180 + dLng := (lng2 - lng1) * math.Pi / 180 + a := math.Sin(dLat/2)*math.Sin(dLat/2) + + math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)* + math.Sin(dLng/2)*math.Sin(dLng/2) + return r * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) +} diff --git a/handlers.go b/handlers.go index acc241d..2105944 100644 --- a/handlers.go +++ b/handlers.go @@ -12,6 +12,12 @@ type apiResponse struct { Count int `json:"count"` } +type routeRequest struct { + Points [][2]float64 `json:"points"` + Band string `json:"band"` + Corridor float64 `json:"corridor"` +} + func handleRepeaters(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -56,3 +62,50 @@ func handleRepeaters(db *sql.DB) http.HandlerFunc { }) } } + +func handleRouteRepeaters(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + var req routeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest) + return + } + + if len(req.Points) < 2 { + http.Error(w, `{"error":"at least 2 route points required"}`, http.StatusBadRequest) + return + } + + if req.Band == "" { + req.Band = "all" + } + if req.Band != "2m" && req.Band != "70cm" && req.Band != "all" { + http.Error(w, `{"error":"band must be 2m, 70cm, or all"}`, http.StatusBadRequest) + return + } + if req.Corridor <= 0 { + req.Corridor = 10 + } + + repeaters, err := queryRepeatersAlongRoute(db, req.Points, req.Corridor, req.Band) + if err != nil { + http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError) + return + } + + if repeaters == nil { + repeaters = []Repeater{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(apiResponse{ + Repeaters: repeaters, + Count: len(repeaters), + }) + } +} diff --git a/main.go b/main.go index 8171101..f74832f 100644 --- a/main.go +++ b/main.go @@ -27,6 +27,7 @@ func main() { mux := http.NewServeMux() mux.HandleFunc("/api/repeaters", handleRepeaters(db)) + mux.HandleFunc("/api/repeaters/route", handleRouteRepeaters(db)) mux.Handle("/", http.FileServer(http.Dir("static"))) log.Printf("Listening on %s", addr) diff --git a/static/app.js b/static/app.js index f9fea41..16a9116 100644 --- a/static/app.js +++ b/static/app.js @@ -1,22 +1,34 @@ (function () { "use strict"; + // === Map Setup === var map = L.map("map").setView([52.37, 9.73], 9); - L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: '© OpenStreetMap contributors', }).addTo(map); + // === State === var markerLayer = L.layerGroup().addTo(map); - var statusBar = document.getElementById("status-bar"); - var band2m = document.getElementById("band-2m"); - var band70cm = document.getElementById("band-70cm"); - + var routeLayer = null; + var routePoints = null; // stored [lat, lng] pairs for re-fetching on band change + var isRouteMode = false; var debounceTimer = null; var controller = null; + // === DOM === + var band2m = document.getElementById("band-2m"); + var band70cm = document.getElementById("band-70cm"); + var countEl = document.getElementById("count"); + var fromInput = document.getElementById("route-from"); + var toInput = document.getElementById("route-to"); + var routeBtn = document.getElementById("route-btn"); + var clearBtn = document.getElementById("clear-btn"); + + clearBtn.style.display = "none"; + + // === Utilities === function getSelectedBand() { var has2m = band2m.checked; var has70cm = band70cm.checked; @@ -35,6 +47,15 @@ .replace(/"/g, """); } + function showCount(count) { + countEl.textContent = count + " repeater" + (count !== 1 ? "s" : ""); + } + + function showStatus(msg) { + countEl.textContent = msg; + } + + // === Popup === function buildPopup(r) { var bandClass = r.band === "2m" ? "band-2m" : "band-70cm"; var html = '