diff --git a/db.go b/db.go index 01e9751..623cdc5 100644 --- a/db.go +++ b/db.go @@ -3,6 +3,7 @@ package main import ( "database/sql" "math" + "sort" "strings" _ "modernc.org/sqlite" @@ -183,6 +184,36 @@ func queryRepeatersAlongRoute(db *sql.DB, points [][2]float64, corridorKm float6 return results, nil } +// Radius query: find repeaters within radiusKm of a point, sorted by distance. +type RepeaterWithDistance struct { + Repeater + Distance float64 `json:"distance"` +} + +func queryRepeatersInRadius(db *sql.DB, lat, lng, radiusKm float64, band string, networks []string, showHotspots bool) ([]RepeaterWithDistance, error) { + latPad := radiusKm / 111.32 + lngPad := radiusKm / (111.32 * math.Cos(lat*math.Pi/180)) + + candidates, err := queryRepeaters(db, lat-latPad, lat+latPad, lng-lngPad, lng+lngPad, band, networks, showHotspots) + if err != nil { + return nil, err + } + + var results []RepeaterWithDistance + for _, r := range candidates { + d := haversineKm(lat, lng, r.Lat, r.Lng) + if d <= radiusKm { + results = append(results, RepeaterWithDistance{Repeater: r, Distance: math.Round(d*10) / 10}) + } + } + + sort.Slice(results, func(i, j int) bool { + return results[i].Distance < results[j].Distance + }) + + return results, nil +} + func minDistToRoute(lat, lng float64, points [][2]float64) float64 { best := math.Inf(1) for i := 0; i < len(points)-1; i++ { diff --git a/handlers.go b/handlers.go index 8b65294..1669bc1 100644 --- a/handlers.go +++ b/handlers.go @@ -73,6 +73,70 @@ func handleRepeaters(db *sql.DB) http.HandlerFunc { } } +type radiusResponse struct { + Repeaters []RepeaterWithDistance `json:"repeaters"` + Count int `json:"count"` +} + +func handleRadiusRepeaters(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + q := r.URL.Query() + lat, err1 := strconv.ParseFloat(q.Get("lat"), 64) + lng, err2 := strconv.ParseFloat(q.Get("lng"), 64) + if err1 != nil || err2 != nil { + http.Error(w, `{"error":"missing or invalid lat/lng"}`, http.StatusBadRequest) + return + } + + radius := 100.0 + if rv := q.Get("radius"); rv != "" { + if parsed, err := strconv.ParseFloat(rv, 64); err == nil && parsed > 0 { + radius = parsed + } + } + if radius > 500 { + radius = 500 + } + + band := q.Get("band") + if band == "" { + band = "all" + } + if band != "2m" && band != "70cm" && band != "all" { + http.Error(w, `{"error":"band must be 2m, 70cm, or all"}`, http.StatusBadRequest) + return + } + + var networks []string + if net := q.Get("network"); net != "" && net != "all" { + networks = strings.Split(net, ",") + } + + showHotspots := q.Get("hotspots") == "1" + + repeaters, err := queryRepeatersInRadius(db, lat, lng, radius, band, networks, showHotspots) + if err != nil { + http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError) + return + } + + if repeaters == nil { + repeaters = []RepeaterWithDistance{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(radiusResponse{ + Repeaters: repeaters, + Count: len(repeaters), + }) + } +} + func handleRouteRepeaters(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/main.go b/main.go index f74832f..03c00ab 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/radius", handleRadiusRepeaters(db)) mux.HandleFunc("/api/repeaters/route", handleRouteRepeaters(db)) mux.Handle("/", http.FileServer(http.Dir("static"))) diff --git a/static/app.js b/static/app.js index 8494fa3..0b93778 100644 --- a/static/app.js +++ b/static/app.js @@ -14,6 +14,10 @@ var routeLayer = null; var routePoints = null; // stored [lat, lng] pairs for re-fetching on band change var isRouteMode = false; + var isPinMode = false; + var pinMarker = null; + var pinCircle = null; + var pinLatLng = null; var debounceTimer = null; var controller = null; @@ -30,6 +34,10 @@ var toInput = document.getElementById("route-to"); var routeBtn = document.getElementById("route-btn"); var clearBtn = document.getElementById("clear-btn"); + var pinControlsEl = document.getElementById("pin-controls"); + var pinRadiusInput = document.getElementById("pin-radius"); + var pinClearBtn = document.getElementById("pin-clear"); + var pinListEl = document.getElementById("pin-list"); clearBtn.style.display = "none"; @@ -278,7 +286,7 @@ // === Viewport mode === function fetchRepeaters() { - if (isRouteMode) return; + if (isRouteMode || isPinMode) return; if (getSelectedNetworks() === "none") { markerLayer.clearLayers(); @@ -401,6 +409,7 @@ var toAddr = toInput.value.trim(); if (!fromAddr || !toAddr) return; + if (isPinMode) clearPin(); routeBtn.disabled = true; showStatus("Geocoding..."); @@ -453,32 +462,130 @@ fetchRepeaters(); } + // === Pin mode === + function fetchPinRepeaters() { + if (!pinLatLng) return; + + if (getSelectedNetworks() === "none") { + markerLayer.clearLayers(); + pinListEl.innerHTML = ""; + showCount(0); + return; + } + + showStatus("Loading..."); + var params = new URLSearchParams({ + lat: pinLatLng.lat, + lng: pinLatLng.lng, + radius: pinRadiusInput.value, + band: getSelectedBand(), + network: getSelectedNetworks(), + hotspots: showHotspots.checked ? "1" : "0", + }); + + fetch("/api/repeaters/radius?" + params) + .then(function (resp) { + if (!resp.ok) throw new Error("HTTP " + resp.status); + return resp.json(); + }) + .then(function (data) { + displayRepeaters(data.repeaters); + showCount(data.count); + renderPinList(data.repeaters); + }) + .catch(function (err) { + console.error("Pin fetch error:", err); + showStatus("Error"); + }); + } + + function renderPinList(repeaters) { + pinListEl.innerHTML = ""; + repeaters.forEach(function (r) { + var item = document.createElement("div"); + item.className = "pin-list-item"; + var bandColor = r.band === "2m" ? "#1976D2" : "#D32F2F"; + item.innerHTML = + '' + escapeHtml(r.callsign) + "" + + '' + r.frequency.toFixed(4) + "" + + '' + r.distance + " km"; + item.addEventListener("click", function () { + map.setView([r.lat, r.lng], 14); + markerLayer.eachLayer(function (layer) { + if (layer.getLatLng && + layer.getLatLng().lat === r.lat && + layer.getLatLng().lng === r.lng) { + layer.openPopup(); + } + }); + }); + pinListEl.appendChild(item); + }); + } + + function placePin(latlng) { + // Clear route mode if active + if (isRouteMode) clearRoute(); + + isPinMode = true; + pinLatLng = latlng; + + if (pinMarker) map.removeLayer(pinMarker); + if (pinCircle) map.removeLayer(pinCircle); + + pinMarker = L.marker(latlng).addTo(map); + pinCircle = L.circle(latlng, { + radius: pinRadiusInput.value * 1000, + color: "#4CAF50", + weight: 2, + fillOpacity: 0.06, + }).addTo(map); + + pinControlsEl.style.display = ""; + fetchPinRepeaters(); + } + + function clearPin() { + if (pinMarker) { map.removeLayer(pinMarker); pinMarker = null; } + if (pinCircle) { map.removeLayer(pinCircle); pinCircle = null; } + pinLatLng = null; + isPinMode = false; + pinControlsEl.style.display = "none"; + pinListEl.innerHTML = ""; + fetchRepeaters(); + } + // === Events === map.on("moveend", function () { clearTimeout(debounceTimer); debounceTimer = setTimeout(fetchRepeaters, 150); }); - band2m.addEventListener("change", function () { - if (isRouteMode) fetchRouteRepeaters(); - else fetchRepeaters(); + map.on("click", function (e) { + placePin(e.latlng); }); - band70cm.addEventListener("change", function () { - if (isRouteMode) fetchRouteRepeaters(); + function refetchActive() { + if (isPinMode) fetchPinRepeaters(); + else if (isRouteMode) fetchRouteRepeaters(); else fetchRepeaters(); - }); + } + + band2m.addEventListener("change", refetchActive); + band70cm.addEventListener("change", refetchActive); [netBm, netDmrplus, netTgif, netOther].forEach(function (cb) { - cb.addEventListener("change", function () { - if (isRouteMode) fetchRouteRepeaters(); - else fetchRepeaters(); - }); + cb.addEventListener("change", refetchActive); }); - showHotspots.addEventListener("change", function () { - if (isRouteMode) fetchRouteRepeaters(); - else fetchRepeaters(); + showHotspots.addEventListener("change", refetchActive); + + pinClearBtn.addEventListener("click", clearPin); + + pinRadiusInput.addEventListener("change", function () { + if (!pinLatLng) return; + if (pinCircle) pinCircle.setRadius(pinRadiusInput.value * 1000); + fetchPinRepeaters(); }); routeBtn.addEventListener("click", findRoute); diff --git a/static/index.html b/static/index.html index 8931189..3f58773 100644 --- a/static/index.html +++ b/static/index.html @@ -37,6 +37,15 @@ +