Allow setting marker and viewing repeaters in radius
This commit is contained in:
parent
0f8507b2dd
commit
c1bca07d0a
6 changed files with 311 additions and 14 deletions
31
db.go
31
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++ {
|
||||
|
|
|
|||
64
handlers.go
64
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 {
|
||||
|
|
|
|||
1
main.go
1
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")))
|
||||
|
||||
|
|
|
|||
135
static/app.js
135
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 =
|
||||
'<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);
|
||||
|
|
|
|||
|
|
@ -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>.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue