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 (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"math"
|
"math"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
|
|
@ -183,6 +184,36 @@ func queryRepeatersAlongRoute(db *sql.DB, points [][2]float64, corridorKm float6
|
||||||
return results, nil
|
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 {
|
func minDistToRoute(lat, lng float64, points [][2]float64) float64 {
|
||||||
best := math.Inf(1)
|
best := math.Inf(1)
|
||||||
for i := 0; i < len(points)-1; i++ {
|
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 {
|
func handleRouteRepeaters(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
|
|
|
||||||
1
main.go
1
main.go
|
|
@ -27,6 +27,7 @@ func main() {
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/api/repeaters", handleRepeaters(db))
|
mux.HandleFunc("/api/repeaters", handleRepeaters(db))
|
||||||
|
mux.HandleFunc("/api/repeaters/radius", handleRadiusRepeaters(db))
|
||||||
mux.HandleFunc("/api/repeaters/route", handleRouteRepeaters(db))
|
mux.HandleFunc("/api/repeaters/route", handleRouteRepeaters(db))
|
||||||
mux.Handle("/", http.FileServer(http.Dir("static")))
|
mux.Handle("/", http.FileServer(http.Dir("static")))
|
||||||
|
|
||||||
|
|
|
||||||
135
static/app.js
135
static/app.js
|
|
@ -14,6 +14,10 @@
|
||||||
var routeLayer = null;
|
var routeLayer = null;
|
||||||
var routePoints = null; // stored [lat, lng] pairs for re-fetching on band change
|
var routePoints = null; // stored [lat, lng] pairs for re-fetching on band change
|
||||||
var isRouteMode = false;
|
var isRouteMode = false;
|
||||||
|
var isPinMode = false;
|
||||||
|
var pinMarker = null;
|
||||||
|
var pinCircle = null;
|
||||||
|
var pinLatLng = null;
|
||||||
var debounceTimer = null;
|
var debounceTimer = null;
|
||||||
var controller = null;
|
var controller = null;
|
||||||
|
|
||||||
|
|
@ -30,6 +34,10 @@
|
||||||
var toInput = document.getElementById("route-to");
|
var toInput = document.getElementById("route-to");
|
||||||
var routeBtn = document.getElementById("route-btn");
|
var routeBtn = document.getElementById("route-btn");
|
||||||
var clearBtn = document.getElementById("clear-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";
|
clearBtn.style.display = "none";
|
||||||
|
|
||||||
|
|
@ -278,7 +286,7 @@
|
||||||
|
|
||||||
// === Viewport mode ===
|
// === Viewport mode ===
|
||||||
function fetchRepeaters() {
|
function fetchRepeaters() {
|
||||||
if (isRouteMode) return;
|
if (isRouteMode || isPinMode) return;
|
||||||
|
|
||||||
if (getSelectedNetworks() === "none") {
|
if (getSelectedNetworks() === "none") {
|
||||||
markerLayer.clearLayers();
|
markerLayer.clearLayers();
|
||||||
|
|
@ -401,6 +409,7 @@
|
||||||
var toAddr = toInput.value.trim();
|
var toAddr = toInput.value.trim();
|
||||||
if (!fromAddr || !toAddr) return;
|
if (!fromAddr || !toAddr) return;
|
||||||
|
|
||||||
|
if (isPinMode) clearPin();
|
||||||
routeBtn.disabled = true;
|
routeBtn.disabled = true;
|
||||||
showStatus("Geocoding...");
|
showStatus("Geocoding...");
|
||||||
|
|
||||||
|
|
@ -453,32 +462,130 @@
|
||||||
fetchRepeaters();
|
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 ===
|
// === Events ===
|
||||||
map.on("moveend", function () {
|
map.on("moveend", function () {
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
debounceTimer = setTimeout(fetchRepeaters, 150);
|
debounceTimer = setTimeout(fetchRepeaters, 150);
|
||||||
});
|
});
|
||||||
|
|
||||||
band2m.addEventListener("change", function () {
|
map.on("click", function (e) {
|
||||||
if (isRouteMode) fetchRouteRepeaters();
|
placePin(e.latlng);
|
||||||
else fetchRepeaters();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
band70cm.addEventListener("change", function () {
|
function refetchActive() {
|
||||||
if (isRouteMode) fetchRouteRepeaters();
|
if (isPinMode) fetchPinRepeaters();
|
||||||
|
else if (isRouteMode) fetchRouteRepeaters();
|
||||||
else fetchRepeaters();
|
else fetchRepeaters();
|
||||||
});
|
}
|
||||||
|
|
||||||
|
band2m.addEventListener("change", refetchActive);
|
||||||
|
band70cm.addEventListener("change", refetchActive);
|
||||||
|
|
||||||
[netBm, netDmrplus, netTgif, netOther].forEach(function (cb) {
|
[netBm, netDmrplus, netTgif, netOther].forEach(function (cb) {
|
||||||
cb.addEventListener("change", function () {
|
cb.addEventListener("change", refetchActive);
|
||||||
if (isRouteMode) fetchRouteRepeaters();
|
|
||||||
else fetchRepeaters();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
showHotspots.addEventListener("change", function () {
|
showHotspots.addEventListener("change", refetchActive);
|
||||||
if (isRouteMode) fetchRouteRepeaters();
|
|
||||||
else fetchRepeaters();
|
pinClearBtn.addEventListener("click", clearPin);
|
||||||
|
|
||||||
|
pinRadiusInput.addEventListener("change", function () {
|
||||||
|
if (!pinLatLng) return;
|
||||||
|
if (pinCircle) pinCircle.setRadius(pinRadiusInput.value * 1000);
|
||||||
|
fetchPinRepeaters();
|
||||||
});
|
});
|
||||||
|
|
||||||
routeBtn.addEventListener("click", findRoute);
|
routeBtn.addEventListener("click", findRoute);
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,15 @@
|
||||||
<button id="route-btn">Route</button>
|
<button id="route-btn">Route</button>
|
||||||
<button id="clear-btn">Clear</button>
|
<button id="clear-btn">Clear</button>
|
||||||
</div>
|
</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 id="coords" class="coords-row"></div>
|
||||||
<div class="attribution">
|
<div class="attribution">
|
||||||
Repeater data provided by <a href="https://radioid.net/database/dumps" target="_blank" rel="noopener">RadioID</a>.
|
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);
|
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 {
|
.attribution {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue