Add basic routing
This commit is contained in:
parent
84b14c26cc
commit
f4d5f282f3
6 changed files with 459 additions and 75 deletions
99
db.go
99
db.go
|
|
@ -2,6 +2,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"math"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
@ -97,3 +98,101 @@ func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band str
|
||||||
|
|
||||||
return results, nil
|
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))
|
||||||
|
}
|
||||||
|
|
|
||||||
53
handlers.go
53
handlers.go
|
|
@ -12,6 +12,12 @@ type apiResponse struct {
|
||||||
Count int `json:"count"`
|
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 {
|
func handleRepeaters(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.MethodGet {
|
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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
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/route", handleRouteRepeaters(db))
|
||||||
mux.Handle("/", http.FileServer(http.Dir("static")))
|
mux.Handle("/", http.FileServer(http.Dir("static")))
|
||||||
|
|
||||||
log.Printf("Listening on %s", addr)
|
log.Printf("Listening on %s", addr)
|
||||||
|
|
|
||||||
240
static/app.js
240
static/app.js
|
|
@ -1,22 +1,34 @@
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
// === Map Setup ===
|
||||||
var map = L.map("map").setView([52.37, 9.73], 9);
|
var map = L.map("map").setView([52.37, 9.73], 9);
|
||||||
|
|
||||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
attribution:
|
attribution:
|
||||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
|
// === State ===
|
||||||
var markerLayer = L.layerGroup().addTo(map);
|
var markerLayer = L.layerGroup().addTo(map);
|
||||||
var statusBar = document.getElementById("status-bar");
|
var routeLayer = null;
|
||||||
var band2m = document.getElementById("band-2m");
|
var routePoints = null; // stored [lat, lng] pairs for re-fetching on band change
|
||||||
var band70cm = document.getElementById("band-70cm");
|
var isRouteMode = false;
|
||||||
|
|
||||||
var debounceTimer = null;
|
var debounceTimer = null;
|
||||||
var controller = 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() {
|
function getSelectedBand() {
|
||||||
var has2m = band2m.checked;
|
var has2m = band2m.checked;
|
||||||
var has70cm = band70cm.checked;
|
var has70cm = band70cm.checked;
|
||||||
|
|
@ -35,6 +47,15 @@
|
||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showCount(count) {
|
||||||
|
countEl.textContent = count + " repeater" + (count !== 1 ? "s" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(msg) {
|
||||||
|
countEl.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Popup ===
|
||||||
function buildPopup(r) {
|
function buildPopup(r) {
|
||||||
var bandClass = r.band === "2m" ? "band-2m" : "band-70cm";
|
var bandClass = r.band === "2m" ? "band-2m" : "band-70cm";
|
||||||
var html = '<div class="rptr-popup">';
|
var html = '<div class="rptr-popup">';
|
||||||
|
|
@ -53,8 +74,7 @@
|
||||||
"<tr><td>Offset</td><td>" +
|
"<tr><td>Offset</td><td>" +
|
||||||
escapeHtml(r.offset) +
|
escapeHtml(r.offset) +
|
||||||
" MHz</td></tr>";
|
" MHz</td></tr>";
|
||||||
html +=
|
html += "<tr><td>CC</td><td>" + r.color_code + "</td></tr>";
|
||||||
"<tr><td>CC</td><td>" + r.color_code + "</td></tr>";
|
|
||||||
var loc = escapeHtml(r.city);
|
var loc = escapeHtml(r.city);
|
||||||
if (r.state) loc += ", " + escapeHtml(r.state);
|
if (r.state) loc += ", " + escapeHtml(r.state);
|
||||||
if (r.country) loc += ", " + escapeHtml(r.country);
|
if (r.country) loc += ", " + escapeHtml(r.country);
|
||||||
|
|
@ -82,10 +102,28 @@
|
||||||
return html;
|
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() {
|
function fetchRepeaters() {
|
||||||
if (controller) {
|
if (isRouteMode) return;
|
||||||
controller.abort();
|
|
||||||
}
|
if (controller) controller.abort();
|
||||||
controller = new AbortController();
|
controller = new AbortController();
|
||||||
|
|
||||||
var bounds = map.getBounds();
|
var bounds = map.getBounds();
|
||||||
|
|
@ -103,43 +141,171 @@
|
||||||
return resp.json();
|
return resp.json();
|
||||||
})
|
})
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
markerLayer.clearLayers();
|
displayRepeaters(data.repeaters);
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Showing " + data.count + " repeaters");
|
console.log("Showing " + data.count + " repeaters");
|
||||||
statusBar.textContent =
|
showCount(data.count);
|
||||||
"Showing " + data.count + " repeater" + (data.count !== 1 ? "s" : "");
|
|
||||||
statusBar.className = "";
|
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
if (err.name === "AbortError") return;
|
if (err.name === "AbortError") return;
|
||||||
console.error("Fetch error:", err);
|
console.error("Fetch error:", err);
|
||||||
statusBar.textContent = "Failed to load repeaters";
|
showStatus("Error");
|
||||||
statusBar.className = "";
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function debouncedFetch() {
|
// === Route mode ===
|
||||||
clearTimeout(debounceTimer);
|
function geocode(address) {
|
||||||
debounceTimer = setTimeout(fetchRepeaters, 150);
|
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);
|
function getRoute(from, to) {
|
||||||
band2m.addEventListener("change", fetchRepeaters);
|
return fetch(
|
||||||
band70cm.addEventListener("change", fetchRepeaters);
|
"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();
|
fetchRepeaters();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,26 @@
|
||||||
<body>
|
<body>
|
||||||
<div id="map"></div>
|
<div id="map"></div>
|
||||||
<div id="controls">
|
<div id="controls">
|
||||||
<label><input type="checkbox" id="band-2m" checked> 2m</label>
|
<div class="controls-row">
|
||||||
<label><input type="checkbox" id="band-70cm" checked> 70cm</label>
|
<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>
|
||||||
<div id="status-bar">Loading...</div>
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
|
|
||||||
119
static/style.css
119
static/style.css
|
|
@ -24,56 +24,113 @@ html, body {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
display: flex;
|
width: 260px;
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#controls label {
|
.controls-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
min-height: 44px;
|
min-height: 36px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.band-2m {
|
||||||
|
color: #1976D2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-70cm {
|
||||||
|
color: #F57C00;
|
||||||
|
}
|
||||||
|
|
||||||
#controls input[type="checkbox"] {
|
#controls input[type="checkbox"] {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
#band-2m:checked + span,
|
.count {
|
||||||
#controls label:first-child {
|
margin-left: auto;
|
||||||
color: #1976D2;
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#controls label:last-child {
|
.route-row {
|
||||||
color: #F57C00;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#status-bar {
|
.route-row input[type="text"] {
|
||||||
position: absolute;
|
width: 100%;
|
||||||
bottom: 0;
|
padding: 7px 10px;
|
||||||
left: 0;
|
border: 1px solid #ccc;
|
||||||
right: 0;
|
border-radius: 5px;
|
||||||
z-index: 1000;
|
|
||||||
background: rgba(255, 255, 255, 0.9);
|
|
||||||
padding: 8px 16px;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #333;
|
outline: none;
|
||||||
text-align: center;
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#status-bar.truncated {
|
.route-row input[type="text"]:focus {
|
||||||
background: rgba(255, 193, 7, 0.9);
|
border-color: #4CAF50;
|
||||||
color: #333;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 {
|
.rptr-popup h3 {
|
||||||
margin: 0 0 6px 0;
|
margin: 0 0 6px 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
|
@ -120,16 +177,8 @@ html, body {
|
||||||
#controls {
|
#controls {
|
||||||
top: 10px;
|
top: 10px;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
|
left: 10px;
|
||||||
|
width: auto;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#controls label {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status-bar {
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue