Add basic routing

This commit is contained in:
Marcus Kida 2026-02-08 14:18:27 +01:00
parent 84b14c26cc
commit f4d5f282f3
6 changed files with 459 additions and 75 deletions

99
db.go
View file

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

View file

@ -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),
})
}
}

View file

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

View file

@ -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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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, "&quot;");
}
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 = '<div class="rptr-popup">';
@ -53,8 +74,7 @@
"<tr><td>Offset</td><td>" +
escapeHtml(r.offset) +
" MHz</td></tr>";
html +=
"<tr><td>CC</td><td>" + r.color_code + "</td></tr>";
html += "<tr><td>CC</td><td>" + r.color_code + "</td></tr>";
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();
})();

View file

@ -11,10 +11,26 @@
<body>
<div id="map"></div>
<div id="controls">
<label><input type="checkbox" id="band-2m" checked> 2m</label>
<label><input type="checkbox" id="band-70cm" checked> 70cm</label>
<div class="controls-row">
<label class="band-label band-2m"><input type="checkbox" id="band-2m" checked> 2m</label>
<label class="band-label band-70cm"><input type="checkbox" id="band-70cm" checked> 70cm</label>
<span id="count" class="count"></span>
</div>
<div class="controls-row route-row">
<input type="text" id="route-from" placeholder="From address" />
</div>
<div class="controls-row route-row">
<input type="text" id="route-to" placeholder="To address" />
</div>
<div class="controls-row route-row">
<button id="route-btn">Route</button>
<button id="clear-btn">Clear</button>
</div>
<div class="attribution">
Data from <a href="https://radioid.net/database/dumps" target="_blank" rel="noopener">radioid.net</a>.
Not affiliated with or endorsed by RadioID.
</div>
</div>
<div id="status-bar">Loading...</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script src="app.js"></script>

View file

@ -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;
}
}