From f4d5f282f3b19d583c1eb445a48e2bb3bcfec81b Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Sun, 8 Feb 2026 14:18:27 +0100 Subject: [PATCH] Add basic routing --- db.go | 99 +++++++++++++++++++ handlers.go | 53 ++++++++++ main.go | 1 + static/app.js | 240 +++++++++++++++++++++++++++++++++++++++------- static/index.html | 22 ++++- static/style.css | 119 ++++++++++++++++------- 6 files changed, 459 insertions(+), 75 deletions(-) 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 = '
'; @@ -53,8 +74,7 @@ "Offset" + escapeHtml(r.offset) + " MHz"; - html += - "CC" + r.color_code + ""; + html += "CC" + r.color_code + ""; var loc = escapeHtml(r.city); if (r.state) loc += ", " + escapeHtml(r.state); if (r.country) loc += ", " + escapeHtml(r.country); @@ -82,10 +102,28 @@ return html; } + // === Display markers === + function displayRepeaters(repeaters) { + markerLayer.clearLayers(); + repeaters.forEach(function (r) { + var color = r.band === "2m" ? "#2196F3" : "#FF9800"; + var marker = L.circleMarker([r.lat, r.lng], { + radius: 6, + fillColor: color, + color: "#fff", + weight: 1, + fillOpacity: 0.85, + }); + marker.bindPopup(buildPopup(r), { maxWidth: 280 }); + markerLayer.addLayer(marker); + }); + } + + // === Viewport mode === function fetchRepeaters() { - if (controller) { - controller.abort(); - } + if (isRouteMode) return; + + if (controller) controller.abort(); controller = new AbortController(); var bounds = map.getBounds(); @@ -103,43 +141,171 @@ return resp.json(); }) .then(function (data) { - markerLayer.clearLayers(); - - data.repeaters.forEach(function (r) { - var color = r.band === "2m" ? "#2196F3" : "#FF9800"; - var marker = L.circleMarker([r.lat, r.lng], { - radius: 6, - fillColor: color, - color: "#fff", - weight: 1, - fillOpacity: 0.85, - }); - marker.bindPopup(buildPopup(r), { maxWidth: 280 }); - markerLayer.addLayer(marker); - }); - + displayRepeaters(data.repeaters); console.log("Showing " + data.count + " repeaters"); - statusBar.textContent = - "Showing " + data.count + " repeater" + (data.count !== 1 ? "s" : ""); - statusBar.className = ""; + showCount(data.count); }) .catch(function (err) { if (err.name === "AbortError") return; console.error("Fetch error:", err); - statusBar.textContent = "Failed to load repeaters"; - statusBar.className = ""; + showStatus("Error"); }); } - function debouncedFetch() { - clearTimeout(debounceTimer); - debounceTimer = setTimeout(fetchRepeaters, 150); + // === Route mode === + function geocode(address) { + return fetch( + "https://nominatim.openstreetmap.org/search?" + + new URLSearchParams({ + q: address, + format: "json", + limit: "1", + }) + ) + .then(function (resp) { + return resp.json(); + }) + .then(function (data) { + if (!data.length) throw new Error("Not found: " + address); + return { + lat: parseFloat(data[0].lat), + lng: parseFloat(data[0].lon), + }; + }); } - map.on("moveend", debouncedFetch); - band2m.addEventListener("change", fetchRepeaters); - band70cm.addEventListener("change", fetchRepeaters); + function getRoute(from, to) { + return fetch( + "https://router.project-osrm.org/route/v1/driving/" + + from.lng + "," + from.lat + ";" + + to.lng + "," + to.lat + + "?overview=full&geometries=geojson" + ) + .then(function (resp) { + return resp.json(); + }) + .then(function (data) { + if (!data.routes || !data.routes.length) + throw new Error("No route found"); + // coordinates are [lng, lat], convert to [lat, lng] + return data.routes[0].geometry.coordinates.map(function (c) { + return [c[1], c[0]]; + }); + }); + } - // Initial load + function fetchRouteRepeaters() { + if (!routePoints) return; + + showStatus("Loading..."); + return fetch("/api/repeaters/route", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + points: routePoints, + band: getSelectedBand(), + corridor: 10, + }), + }) + .then(function (resp) { + if (!resp.ok) throw new Error("HTTP " + resp.status); + return resp.json(); + }) + .then(function (data) { + displayRepeaters(data.repeaters); + console.log("Showing " + data.count + " repeaters along route"); + showCount(data.count); + }) + .catch(function (err) { + console.error("Route fetch error:", err); + showStatus("Error"); + }); + } + + function findRoute() { + var fromAddr = fromInput.value.trim(); + var toAddr = toInput.value.trim(); + if (!fromAddr || !toAddr) return; + + routeBtn.disabled = true; + showStatus("Geocoding..."); + + geocode(fromAddr) + .then(function (from) { + return geocode(toAddr).then(function (to) { + return { from: from, to: to }; + }); + }) + .then(function (endpoints) { + showStatus("Routing..."); + return getRoute(endpoints.from, endpoints.to); + }) + .then(function (latLngs) { + // Draw route on map + if (routeLayer) map.removeLayer(routeLayer); + routeLayer = L.polyline(latLngs, { + color: "#4CAF50", + weight: 4, + opacity: 0.8, + }).addTo(map); + map.fitBounds(routeLayer.getBounds(), { padding: [50, 50] }); + + // Switch to route mode + routePoints = latLngs; + isRouteMode = true; + clearBtn.style.display = ""; + + return fetchRouteRepeaters(); + }) + .catch(function (err) { + console.error("Route error:", err); + showStatus(err.message); + }) + .then(function () { + routeBtn.disabled = false; + }); + } + + function clearRoute() { + if (routeLayer) { + map.removeLayer(routeLayer); + routeLayer = null; + } + routePoints = null; + isRouteMode = false; + clearBtn.style.display = "none"; + fromInput.value = ""; + toInput.value = ""; + fetchRepeaters(); + } + + // === Events === + map.on("moveend", function () { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(fetchRepeaters, 150); + }); + + band2m.addEventListener("change", function () { + if (isRouteMode) fetchRouteRepeaters(); + else fetchRepeaters(); + }); + + band70cm.addEventListener("change", function () { + if (isRouteMode) fetchRouteRepeaters(); + else fetchRepeaters(); + }); + + routeBtn.addEventListener("click", findRoute); + clearBtn.addEventListener("click", clearRoute); + + toInput.addEventListener("keydown", function (e) { + if (e.key === "Enter") findRoute(); + }); + + fromInput.addEventListener("keydown", function (e) { + if (e.key === "Enter") toInput.focus(); + }); + + // === Init === fetchRepeaters(); })(); diff --git a/static/index.html b/static/index.html index fe165a6..1f2715d 100644 --- a/static/index.html +++ b/static/index.html @@ -11,10 +11,26 @@
- - +
+ + + +
+
+ +
+
+ +
+
+ + +
+
+ Data from radioid.net. + Not affiliated with or endorsed by RadioID. +
-
Loading...
diff --git a/static/style.css b/static/style.css index 57ef129..aaec2a1 100644 --- a/static/style.css +++ b/static/style.css @@ -24,56 +24,113 @@ html, body { border-radius: 8px; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); padding: 10px 14px; - display: flex; - gap: 12px; + width: 260px; } -#controls label { +.controls-row { display: flex; align-items: center; - gap: 6px; + gap: 8px; +} + +.band-label { + display: flex; + align-items: center; + gap: 5px; cursor: pointer; font-size: 14px; font-weight: 600; - min-height: 44px; + min-height: 36px; user-select: none; } +.band-2m { + color: #1976D2; +} + +.band-70cm { + color: #F57C00; +} + #controls input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; } -#band-2m:checked + span, -#controls label:first-child { - color: #1976D2; +.count { + margin-left: auto; + font-size: 12px; + color: #888; + white-space: nowrap; } -#controls label:last-child { - color: #F57C00; +.route-row { + margin-top: 6px; } -#status-bar { - position: absolute; - bottom: 0; - left: 0; - right: 0; - z-index: 1000; - background: rgba(255, 255, 255, 0.9); - padding: 8px 16px; +.route-row input[type="text"] { + width: 100%; + padding: 7px 10px; + border: 1px solid #ccc; + border-radius: 5px; font-size: 13px; - color: #333; - text-align: center; - backdrop-filter: blur(4px); + outline: none; } -#status-bar.truncated { - background: rgba(255, 193, 7, 0.9); - color: #333; - font-weight: 600; +.route-row input[type="text"]:focus { + border-color: #4CAF50; } +.route-row button { + flex: 1; + padding: 7px 0; + border: none; + border-radius: 5px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + min-height: 36px; +} + +#route-btn { + background: #4CAF50; + color: white; +} + +#route-btn:hover { + background: #43A047; +} + +#route-btn:disabled { + background: #A5D6A7; + cursor: default; +} + +#clear-btn { + background: #eee; + color: #333; +} + +#clear-btn:hover { + background: #ddd; +} + +.attribution { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid #eee; + font-size: 10px; + color: #999; + line-height: 1.4; +} + +.attribution a { + color: #888; +} + +/* Popups */ + .rptr-popup h3 { margin: 0 0 6px 0; font-size: 15px; @@ -120,16 +177,8 @@ html, body { #controls { top: 10px; right: 10px; + left: 10px; + width: auto; padding: 8px 10px; - gap: 8px; - } - - #controls label { - font-size: 13px; - } - - #status-bar { - font-size: 12px; - padding: 6px 12px; } }