Allow setting marker and viewing repeaters in radius

This commit is contained in:
Marcus Kida 2026-02-08 20:05:30 +01:00
parent 0f8507b2dd
commit c1bca07d0a
6 changed files with 311 additions and 14 deletions

31
db.go
View file

@ -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++ {

View file

@ -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 {

View file

@ -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")))

View file

@ -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 =
'<span class="callsign">' + escapeHtml(r.callsign) + "</span>" +
'<span class="freq" style="color:' + bandColor + '">' + r.frequency.toFixed(4) + "</span>" +
'<span class="dist">' + r.distance + " km</span>";
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);

View file

@ -37,6 +37,15 @@
<button id="route-btn">Route</button>
<button id="clear-btn">Clear</button>
</div>
<div id="pin-controls" class="pin-controls" style="display:none">
<div class="controls-row pin-header">
<label class="pin-radius-label">Radius
<input type="number" id="pin-radius" value="100" min="1" max="500" step="10" /> km
</label>
<button id="pin-clear" class="pin-clear-btn">Clear pin</button>
</div>
<div id="pin-list" class="pin-list"></div>
</div>
<div id="coords" class="coords-row"></div>
<div class="attribution">
Repeater data provided by <a href="https://radioid.net/database/dumps" target="_blank" rel="noopener">RadioID</a>.

View file

@ -332,6 +332,91 @@ html, body {
color: var(--text-fainter);
}
.pin-controls {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--border-light);
}
.pin-header {
justify-content: space-between;
}
.pin-radius-label {
font-size: 12px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 4px;
}
.pin-radius-label input {
width: 52px;
padding: 3px 4px;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 12px;
text-align: right;
background: var(--input-bg);
color: var(--text);
}
.pin-clear-btn {
background: var(--clear-btn-bg);
color: var(--text-muted);
border: none;
border-radius: 4px;
padding: 3px 8px;
font-size: 11px;
cursor: pointer;
}
.pin-clear-btn:hover {
background: var(--clear-btn-hover);
}
.pin-list {
max-height: 220px;
overflow-y: auto;
margin-top: 4px;
}
.pin-list-item {
display: flex;
align-items: baseline;
gap: 6px;
padding: 4px 2px;
border-bottom: 1px solid var(--border-lighter);
font-size: 12px;
cursor: pointer;
}
.pin-list-item:last-child {
border-bottom: none;
}
.pin-list-item:hover {
background: var(--hover-bg);
}
.pin-list-item .callsign {
font-weight: 600;
color: var(--text);
min-width: 70px;
}
.pin-list-item .freq {
color: var(--text-muted);
font-size: 11px;
}
.pin-list-item .dist {
margin-left: auto;
color: var(--text-fainter);
font-size: 11px;
white-space: nowrap;
}
.attribution {
margin-top: 8px;
padding-top: 8px;