Add geo coords and maidenhead display, minor improvements
This commit is contained in:
parent
a12410d378
commit
625e58ca05
10 changed files with 65 additions and 179 deletions
|
|
@ -1,8 +1,8 @@
|
|||
root = "."
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./tmp/repeaterroute ."
|
||||
bin = "./tmp/repeaterroute"
|
||||
cmd = "go build -o ./tmp/dmrmap ."
|
||||
bin = "./tmp/dmrmap"
|
||||
include_ext = ["go"]
|
||||
exclude_dir = ["tmp", "static", "data"]
|
||||
delay = 500
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,3 +1,3 @@
|
|||
tmp/
|
||||
data/
|
||||
repeaterroute
|
||||
dmrmap
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ WORKDIR /build
|
|||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY *.go ./
|
||||
RUN CGO_ENABLED=0 go build -o repeaterroute .
|
||||
RUN CGO_ENABLED=0 go build -o dmrmap .
|
||||
|
||||
FROM alpine:3.19
|
||||
WORKDIR /app
|
||||
COPY --from=builder /build/repeaterroute .
|
||||
COPY --from=builder /build/dmrmap .
|
||||
COPY static/ ./static/
|
||||
COPY rptrs.json .
|
||||
EXPOSE 8080
|
||||
CMD ["./repeaterroute"]
|
||||
CMD ["./dmrmap"]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
services:
|
||||
repeaterroute:
|
||||
dmrmap:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
services:
|
||||
repeaterroute:
|
||||
dmrmap:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -1,4 +1,4 @@
|
|||
module repeaterroute
|
||||
module dmrmap
|
||||
|
||||
go 1.25.7
|
||||
|
||||
|
|
|
|||
167
plan.md
167
plan.md
|
|
@ -1,167 +0,0 @@
|
|||
RepeaterRoute - DMR Repeater Map
|
||||
Context
|
||||
The project contains rptrs.json (4.1MB, 10,607 DMR repeaters) with no application code yet. Goal: a server-backed responsive web app that visualizes repeaters on a full-screen OpenStreetMap with band filtering and viewport-based loading. The server seeds a SQLite DB from the JSON on startup, serves a REST API for geo-filtered queries, and serves the frontend assets. Deployed via Docker.
|
||||
|
||||
Language: Go
|
||||
Recommended over Python/Node/Rust because:
|
||||
|
||||
Single binary, ~12MB Docker image (vs ~120-200MB for Python/Node)
|
||||
modernc.org/sqlite is pure Go — no CGO, no C compiler in Docker
|
||||
net/http stdlib is production-grade — no framework needed
|
||||
Only 1 external dependency (modernc.org/sqlite)
|
||||
Fast builds, simple deployment
|
||||
File Structure
|
||||
|
||||
repeaterroute/
|
||||
go.mod / go.sum # Go module (dep: modernc.org/sqlite)
|
||||
main.go # Entry point: env config, seed, start server
|
||||
seed.go # Parse rptrs.json → SQLite
|
||||
db.go # Schema, openDB, queryRepeaters
|
||||
handlers.go # GET /api/repeaters handler
|
||||
static/
|
||||
index.html # Leaflet map shell
|
||||
style.css # Full-screen map, controls, responsive
|
||||
app.js # Map init, viewport queries, markers, filters
|
||||
rptrs.json # Existing data (unchanged)
|
||||
Dockerfile # Multi-stage: golang:1.22-alpine → alpine:3.19
|
||||
docker-compose.yml # Service + named volume for DB persistence
|
||||
Database Schema (db.go)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS repeaters (
|
||||
id INTEGER PRIMARY KEY,
|
||||
callsign TEXT NOT NULL,
|
||||
frequency REAL NOT NULL,
|
||||
band TEXT NOT NULL, -- '2m', '70cm', or 'other'
|
||||
lat REAL NOT NULL,
|
||||
lng REAL NOT NULL,
|
||||
city TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
color_code INTEGER NOT NULL DEFAULT 1,
|
||||
offset TEXT NOT NULL DEFAULT '',
|
||||
ts_linked TEXT NOT NULL DEFAULT '',
|
||||
trustee TEXT NOT NULL DEFAULT '',
|
||||
ipsc_network TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'ACTIVE'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_repeaters_lat_band ON repeaters (lat, band);
|
||||
CREATE INDEX idx_repeaters_lng ON repeaters (lng);
|
||||
Band is computed at seed time: 144-148 MHz → "2m", 420-450 MHz → "70cm", else "other".
|
||||
|
||||
API
|
||||
GET /api/repeaters
|
||||
Param Type Required Description
|
||||
minLat float yes South boundary
|
||||
maxLat float yes North boundary
|
||||
minLng float yes West boundary
|
||||
maxLng float yes East boundary
|
||||
band string no "2m", "70cm", or "all" (default)
|
||||
Response:
|
||||
|
||||
|
||||
{
|
||||
"repeaters": [{ "id": 1, "callsign": "DB0XYZ", "frequency": 439.5, "band": "70cm", "lat": 52.1, "lng": 9.7, ... }],
|
||||
"count": 847,
|
||||
"truncated": false
|
||||
}
|
||||
Query uses LIMIT 1001 — if 1001 rows returned, set truncated: true and return only first 1000. No separate COUNT query needed.
|
||||
|
||||
When band is "all", query uses AND band IN ('2m', '70cm') to exclude "other".
|
||||
|
||||
Static files
|
||||
/ and all non-API paths → http.FileServer(http.Dir("static"))
|
||||
|
||||
Seed Logic (seed.go)
|
||||
Runs at startup inside main(), not as a separate script:
|
||||
|
||||
Create table + indexes if not exist
|
||||
SELECT COUNT(*) — if > 0, skip (already seeded)
|
||||
Read rptrs.json, decode {"rptrs": [...]}
|
||||
Single transaction, prepared INSERT
|
||||
Per entry: parse lat/lng (strings→float64, skip if empty/NaN/out-of-range/null-island), parse frequency (skip if 0), classify band, insert
|
||||
Expected: ~9,870 inserted, ~740 skipped
|
||||
Coordinate validation: skip if lat/lng empty, NaN, both zero, or outside [-90,90]/[-180,180].
|
||||
|
||||
Frontend (static/)
|
||||
Map (app.js)
|
||||
Leaflet 1.9.4 from CDN (no marker clustering needed — max 1000 markers)
|
||||
Centered on Hannover: [52.37, 9.73], zoom 6
|
||||
L.layerGroup() for markers — cleared and rebuilt on each fetch
|
||||
L.circleMarker with band colors: blue #2196F3 (2m), orange #FF9800 (70cm)
|
||||
Debounced moveend listener (150ms) triggers fetchRepeaters()
|
||||
fetchRepeaters(): extracts bounds → calls API → clears layer → adds markers → updates status bar
|
||||
Popup: callsign, frequency+band, offset, color code, city/state/country, network, trustee, timeslots, status
|
||||
Filter UI
|
||||
Two checkboxes: "2m" and "70cm" (both checked by default)
|
||||
On change → fetchRepeaters()
|
||||
Both checked = band=all, one checked = that band, neither = all (show everything)
|
||||
Status bar
|
||||
Bottom of screen: "Showing X repeaters"
|
||||
When truncated: amber background, "Showing 1000 repeaters — zoom in to see all"
|
||||
Responsive
|
||||
#map: 100vw × 100vh, no margin
|
||||
Controls: top-right overlay, touch-friendly (min 44px tap targets)
|
||||
Mobile (<768px): controls compact horizontally
|
||||
Docker
|
||||
Dockerfile
|
||||
|
||||
FROM golang:1.22-alpine AS builder
|
||||
WORKDIR /build
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY *.go ./
|
||||
RUN CGO_ENABLED=0 go build -o repeaterroute .
|
||||
|
||||
FROM alpine:3.19
|
||||
WORKDIR /app
|
||||
COPY --from=builder /build/repeaterroute .
|
||||
COPY static/ ./static/
|
||||
COPY rptrs.json .
|
||||
EXPOSE 8080
|
||||
CMD ["./repeaterroute"]
|
||||
docker-compose.yml
|
||||
|
||||
services:
|
||||
repeaterroute:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- repeater-data:/app/data
|
||||
environment:
|
||||
- DB_PATH=/app/data/repeaters.db
|
||||
- JSON_PATH=/app/rptrs.json
|
||||
- LISTEN_ADDR=:8080
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
repeater-data:
|
||||
Named volume persists SQLite DB across container restarts (no re-seeding).
|
||||
|
||||
Implementation Steps
|
||||
Init Go module — go mod init repeaterroute && go get modernc.org/sqlite
|
||||
db.go — Repeater struct, schema const, openDB(), queryRepeaters()
|
||||
seed.go — JSON parsing, coord/freq validation, band classification, bulk insert
|
||||
handlers.go — Parameter parsing, validation, JSON response
|
||||
main.go — Env config, seed call, route registration, ListenAndServe
|
||||
static/index.html — HTML shell with Leaflet CDN, control panel, status bar
|
||||
static/style.css — Full-screen map, overlay controls, responsive breakpoints
|
||||
static/app.js — Map init, debounced viewport fetching, markers, filters, popups
|
||||
Dockerfile — Multi-stage build
|
||||
docker-compose.yml — Service + volume
|
||||
Verification
|
||||
|
||||
# Local dev
|
||||
go run . && open http://localhost:8080
|
||||
|
||||
# Docker
|
||||
docker compose up --build && open http://localhost:8080
|
||||
Map loads centered on Hannover/Germany at zoom 6
|
||||
Repeaters appear as blue (2m) and orange (70cm) circle markers
|
||||
Pan/zoom triggers new fetch (check browser network tab)
|
||||
Band checkboxes filter correctly
|
||||
Zoom out to world view → "Zoom in to see all repeaters" message appears
|
||||
Click marker → popup with repeater details
|
||||
Status bar shows count, updates on each fetch
|
||||
docker compose up --build starts cleanly, seeds DB on first run, skips on restart
|
||||
|
|
@ -72,6 +72,7 @@
|
|||
format: "json",
|
||||
limit: "5",
|
||||
addressdetails: "0",
|
||||
"accept-language": navigator.language,
|
||||
}),
|
||||
{ signal: acController.signal }
|
||||
)
|
||||
|
|
@ -135,6 +136,39 @@
|
|||
setupAutocomplete(fromInput, document.getElementById("ac-from"));
|
||||
setupAutocomplete(toInput, document.getElementById("ac-to"));
|
||||
|
||||
// === Coordinates display ===
|
||||
var coordsEl = document.getElementById("coords");
|
||||
|
||||
function toMaidenhead(lat, lng) {
|
||||
lng = lng + 180;
|
||||
lat = lat + 90;
|
||||
var loc = "";
|
||||
loc += String.fromCharCode(65 + Math.floor(lng / 20));
|
||||
loc += String.fromCharCode(65 + Math.floor(lat / 10));
|
||||
lng = (lng % 20);
|
||||
lat = (lat % 10);
|
||||
loc += Math.floor(lng / 2);
|
||||
loc += Math.floor(lat);
|
||||
lng = (lng % 2) * 60;
|
||||
lat = (lat % 1) * 60;
|
||||
loc += String.fromCharCode(97 + Math.floor(lng / 5));
|
||||
loc += String.fromCharCode(97 + Math.floor(lat / 2.5));
|
||||
return loc;
|
||||
}
|
||||
|
||||
map.on("mousemove", function (e) {
|
||||
var lat = e.latlng.lat;
|
||||
var lng = e.latlng.lng;
|
||||
var grid = toMaidenhead(lat, lng);
|
||||
coordsEl.innerHTML =
|
||||
lat.toFixed(5) + ", " + lng.toFixed(5) +
|
||||
' <span class="maidenhead">' + grid + "</span>";
|
||||
});
|
||||
|
||||
map.on("mouseout", function () {
|
||||
coordsEl.innerHTML = "";
|
||||
});
|
||||
|
||||
// === Utilities ===
|
||||
function getSelectedBand() {
|
||||
var has2m = band2m.checked;
|
||||
|
|
@ -267,6 +301,7 @@
|
|||
q: address,
|
||||
format: "json",
|
||||
limit: "1",
|
||||
"accept-language": navigator.language,
|
||||
})
|
||||
)
|
||||
.then(function (resp) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>RepeaterRoute</title>
|
||||
<title>DMRmap</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
|
|
@ -28,9 +28,14 @@
|
|||
<button id="route-btn">Route</button>
|
||||
<button id="clear-btn">Clear</button>
|
||||
</div>
|
||||
<div id="coords" class="coords-row"></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.
|
||||
Repeater data provided by <a href="https://radioid.net/database/dumps" target="_blank" rel="noopener">RadioID</a>.
|
||||
Not affiliated with or endorsed by RadioID.<br>
|
||||
Geocoding by <a href="https://nominatim.openstreetmap.org" target="_blank" rel="noopener">Nominatim</a>.
|
||||
Routing by <a href="https://project-osrm.org" target="_blank" rel="noopener">OSRM</a>.
|
||||
Map by <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>.<br>
|
||||
Built by DA6KM. <a href="https://git.kida.io/marcus/DMRmap" target="_blank" rel="noopener">Source code</a>.
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
|
|
|
|||
|
|
@ -167,6 +167,19 @@ html, body {
|
|||
background: #ddd;
|
||||
}
|
||||
|
||||
.coords-row {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace;
|
||||
line-height: 1.5;
|
||||
min-height: 1.5em;
|
||||
}
|
||||
|
||||
.coords-row .maidenhead {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.attribution {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue