Initial Commit
This commit is contained in:
commit
84b14c26cc
17 changed files with 941 additions and 0 deletions
11
.air.toml
Normal file
11
.air.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
root = "."
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./tmp/repeaterroute ."
|
||||
bin = "./tmp/repeaterroute"
|
||||
include_ext = ["go"]
|
||||
exclude_dir = ["tmp", "static", "data"]
|
||||
delay = 500
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
tmp/
|
||||
data/
|
||||
repeaterroute
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
FROM golang:1.25-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"]
|
||||
4
Dockerfile.dev
Normal file
4
Dockerfile.dev
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
FROM golang:1.25-alpine
|
||||
RUN go install github.com/air-verse/air@latest
|
||||
WORKDIR /app
|
||||
CMD ["air"]
|
||||
21
compose.dev.yml
Normal file
21
compose.dev.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
services:
|
||||
repeaterroute:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- .:/app
|
||||
- go-mod-cache:/go/pkg/mod
|
||||
- go-build-cache:/root/.cache/go-build
|
||||
- repeater-data:/app/data
|
||||
environment:
|
||||
- DB_PATH=/app/data/repeaters.db
|
||||
- JSON_PATH=/app/rptrs.json
|
||||
- LISTEN_ADDR=:8080
|
||||
|
||||
volumes:
|
||||
go-mod-cache:
|
||||
go-build-cache:
|
||||
repeater-data:
|
||||
15
compose.yml
Normal file
15
compose.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
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:
|
||||
99
db.go
Normal file
99
db.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const schemaSQL = `
|
||||
CREATE TABLE IF NOT EXISTS repeaters (
|
||||
id INTEGER PRIMARY KEY,
|
||||
callsign TEXT NOT NULL,
|
||||
frequency REAL NOT NULL,
|
||||
band TEXT NOT NULL,
|
||||
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 IF NOT EXISTS idx_repeaters_lat_band ON repeaters (lat, band);
|
||||
CREATE INDEX IF NOT EXISTS idx_repeaters_lng ON repeaters (lng);
|
||||
`
|
||||
|
||||
type Repeater struct {
|
||||
ID int `json:"id"`
|
||||
Callsign string `json:"callsign"`
|
||||
Frequency float64 `json:"frequency"`
|
||||
Band string `json:"band"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
City string `json:"city"`
|
||||
State string `json:"state"`
|
||||
Country string `json:"country"`
|
||||
ColorCode int `json:"color_code"`
|
||||
Offset string `json:"offset"`
|
||||
TsLinked string `json:"ts_linked"`
|
||||
Trustee string `json:"trustee"`
|
||||
IpscNetwork string `json:"ipsc_network"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func openDB(path string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band string) ([]Repeater, error) {
|
||||
baseQuery := `SELECT id, callsign, frequency, band, lat, lng, city, state, country,
|
||||
color_code, offset, ts_linked, trustee, ipsc_network, status
|
||||
FROM repeaters WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?`
|
||||
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
switch band {
|
||||
case "2m":
|
||||
rows, err = db.Query(baseQuery+" AND band = ?", minLat, maxLat, minLng, maxLng, "2m")
|
||||
case "70cm":
|
||||
rows, err = db.Query(baseQuery+" AND band = ?", minLat, maxLat, minLng, maxLng, "70cm")
|
||||
default:
|
||||
rows, err = db.Query(baseQuery+" AND band IN ('2m', '70cm')", minLat, maxLat, minLng, maxLng)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []Repeater
|
||||
for rows.Next() {
|
||||
var r Repeater
|
||||
if err := rows.Scan(&r.ID, &r.Callsign, &r.Frequency, &r.Band,
|
||||
&r.Lat, &r.Lng, &r.City, &r.State, &r.Country,
|
||||
&r.ColorCode, &r.Offset, &r.TsLinked, &r.Trustee,
|
||||
&r.IpscNetwork, &r.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
17
go.mod
Normal file
17
go.mod
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
module repeaterroute
|
||||
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.44.3 // indirect
|
||||
)
|
||||
23
go.sum
Normal file
23
go.sum
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
|
||||
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
58
handlers.go
Normal file
58
handlers.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type apiResponse struct {
|
||||
Repeaters []Repeater `json:"repeaters"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func handleRepeaters(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()
|
||||
minLat, err1 := strconv.ParseFloat(q.Get("minLat"), 64)
|
||||
maxLat, err2 := strconv.ParseFloat(q.Get("maxLat"), 64)
|
||||
minLng, err3 := strconv.ParseFloat(q.Get("minLng"), 64)
|
||||
maxLng, err4 := strconv.ParseFloat(q.Get("maxLng"), 64)
|
||||
|
||||
if err1 != nil || err2 != nil || err3 != nil || err4 != nil {
|
||||
http.Error(w, `{"error":"missing or invalid bounding box parameters (minLat, maxLat, minLng, maxLng)"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
repeaters, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
41
main.go
Normal file
41
main.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dbPath := envOr("DB_PATH", "data/repeaters.db")
|
||||
jsonPath := envOr("JSON_PATH", "rptrs.json")
|
||||
addr := envOr("LISTEN_ADDR", ":8080")
|
||||
|
||||
if err := os.MkdirAll("data", 0755); err != nil {
|
||||
log.Fatalf("Failed to create data directory: %v", err)
|
||||
}
|
||||
|
||||
if err := seedDatabase(dbPath, jsonPath); err != nil {
|
||||
log.Fatalf("Failed to seed database: %v", err)
|
||||
}
|
||||
|
||||
db, err := openDB(dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/repeaters", handleRepeaters(db))
|
||||
mux.Handle("/", http.FileServer(http.Dir("static")))
|
||||
|
||||
log.Printf("Listening on %s", addr)
|
||||
log.Fatal(http.ListenAndServe(addr, mux))
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
167
plan.md
Normal file
167
plan.md
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
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
|
||||
1
rptrs.json
Normal file
1
rptrs.json
Normal file
File diff suppressed because one or more lines are too long
165
seed.go
Normal file
165
seed.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type rawRepeater struct {
|
||||
ID int `json:"id"`
|
||||
Callsign string `json:"callsign"`
|
||||
Frequency string `json:"frequency"`
|
||||
Lat interface{} `json:"lat"`
|
||||
Lng interface{} `json:"lng"`
|
||||
City string `json:"city"`
|
||||
State string `json:"state"`
|
||||
Country string `json:"country"`
|
||||
ColorCode int `json:"color_code"`
|
||||
Offset string `json:"offset"`
|
||||
TsLinked string `json:"ts_linked"`
|
||||
Trustee string `json:"trustee"`
|
||||
IpscNetwork string `json:"ipsc_network"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type rawData struct {
|
||||
Rptrs []rawRepeater `json:"rptrs"`
|
||||
}
|
||||
|
||||
func seedDatabase(dbPath, jsonPath string) error {
|
||||
db, err := openDB(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := db.Exec(schemaSQL); err != nil {
|
||||
return fmt.Errorf("create schema: %w", err)
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM repeaters").Scan(&count); err != nil {
|
||||
return fmt.Errorf("count check: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
log.Printf("Database already seeded with %d repeaters", count)
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.Open(jsonPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open json: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var raw rawData
|
||||
if err := json.NewDecoder(f).Decode(&raw); err != nil {
|
||||
return fmt.Errorf("decode json: %w", err)
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
|
||||
stmt, err := tx.Prepare(`INSERT INTO repeaters
|
||||
(id, callsign, frequency, band, lat, lng, city, state, country,
|
||||
color_code, offset, ts_linked, trustee, ipsc_network, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("prepare stmt: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
inserted, skipped := 0, 0
|
||||
for _, r := range raw.Rptrs {
|
||||
lat, lng, ok := parseCoords(r.Lat, r.Lng)
|
||||
if !ok {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
freq, ok := parseFrequency(r.Frequency)
|
||||
if !ok {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
band := classifyBand(freq)
|
||||
|
||||
if _, err := stmt.Exec(r.ID, r.Callsign, freq, band, lat, lng,
|
||||
r.City, r.State, r.Country, r.ColorCode, r.Offset,
|
||||
r.TsLinked, r.Trustee, r.IpscNetwork, r.Status); err != nil {
|
||||
log.Printf("Warning: skipping repeater %d (%s): %v", r.ID, r.Callsign, err)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
inserted++
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Seeded %d repeaters (%d skipped)", inserted, skipped)
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCoords(rawLat, rawLng interface{}) (float64, float64, bool) {
|
||||
lat := toFloat(rawLat)
|
||||
lng := toFloat(rawLng)
|
||||
if math.IsNaN(lat) || math.IsNaN(lng) {
|
||||
return 0, 0, false
|
||||
}
|
||||
if lat == 0 && lng == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
if lat < -90 || lat > 90 || lng < -180 || lng > 180 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return lat, lng, true
|
||||
}
|
||||
|
||||
func toFloat(v interface{}) float64 {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
if val == "" {
|
||||
return math.NaN()
|
||||
}
|
||||
f, err := strconv.ParseFloat(val, 64)
|
||||
if err != nil {
|
||||
return math.NaN()
|
||||
}
|
||||
return f
|
||||
case float64:
|
||||
return val
|
||||
case nil:
|
||||
return math.NaN()
|
||||
default:
|
||||
return math.NaN()
|
||||
}
|
||||
}
|
||||
|
||||
func parseFrequency(s string) (float64, bool) {
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil || f == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
|
||||
func classifyBand(freq float64) string {
|
||||
if freq >= 144 && freq <= 148 {
|
||||
return "2m"
|
||||
}
|
||||
if freq >= 420 && freq <= 450 {
|
||||
return "70cm"
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
145
static/app.js
Normal file
145
static/app.js
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
(function () {
|
||||
"use strict";
|
||||
|
||||
var map = L.map("map").setView([52.37, 9.73], 9);
|
||||
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19,
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
|
||||
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 debounceTimer = null;
|
||||
var controller = null;
|
||||
|
||||
function getSelectedBand() {
|
||||
var has2m = band2m.checked;
|
||||
var has70cm = band70cm.checked;
|
||||
if (has2m && has70cm) return "all";
|
||||
if (has2m) return "2m";
|
||||
if (has70cm) return "70cm";
|
||||
return "all";
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function buildPopup(r) {
|
||||
var bandClass = r.band === "2m" ? "band-2m" : "band-70cm";
|
||||
var html = '<div class="rptr-popup">';
|
||||
html += "<h3>" + escapeHtml(r.callsign) + "</h3>";
|
||||
html += "<table>";
|
||||
html +=
|
||||
"<tr><td>Freq</td><td>" +
|
||||
r.frequency.toFixed(5) +
|
||||
' MHz <span class="band-tag ' +
|
||||
bandClass +
|
||||
'">' +
|
||||
escapeHtml(r.band) +
|
||||
"</span></td></tr>";
|
||||
if (r.offset)
|
||||
html +=
|
||||
"<tr><td>Offset</td><td>" +
|
||||
escapeHtml(r.offset) +
|
||||
" MHz</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);
|
||||
html += "<tr><td>Location</td><td>" + loc + "</td></tr>";
|
||||
if (r.ipsc_network)
|
||||
html +=
|
||||
"<tr><td>Network</td><td>" +
|
||||
escapeHtml(r.ipsc_network) +
|
||||
"</td></tr>";
|
||||
if (r.trustee)
|
||||
html +=
|
||||
"<tr><td>Trustee</td><td>" +
|
||||
escapeHtml(r.trustee) +
|
||||
"</td></tr>";
|
||||
if (r.ts_linked)
|
||||
html +=
|
||||
"<tr><td>Timeslots</td><td>" +
|
||||
escapeHtml(r.ts_linked) +
|
||||
"</td></tr>";
|
||||
html +=
|
||||
"<tr><td>Status</td><td>" +
|
||||
escapeHtml(r.status) +
|
||||
"</td></tr>";
|
||||
html += "</table></div>";
|
||||
return html;
|
||||
}
|
||||
|
||||
function fetchRepeaters() {
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
}
|
||||
controller = new AbortController();
|
||||
|
||||
var bounds = map.getBounds();
|
||||
var params = new URLSearchParams({
|
||||
minLat: bounds.getSouth(),
|
||||
maxLat: bounds.getNorth(),
|
||||
minLng: bounds.getWest(),
|
||||
maxLng: bounds.getEast(),
|
||||
band: getSelectedBand(),
|
||||
});
|
||||
|
||||
fetch("/api/repeaters?" + params, { signal: controller.signal })
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||
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);
|
||||
});
|
||||
|
||||
console.log("Showing " + data.count + " repeaters");
|
||||
statusBar.textContent =
|
||||
"Showing " + data.count + " repeater" + (data.count !== 1 ? "s" : "");
|
||||
statusBar.className = "";
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (err.name === "AbortError") return;
|
||||
console.error("Fetch error:", err);
|
||||
statusBar.textContent = "Failed to load repeaters";
|
||||
statusBar.className = "";
|
||||
});
|
||||
}
|
||||
|
||||
function debouncedFetch() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(fetchRepeaters, 150);
|
||||
}
|
||||
|
||||
map.on("moveend", debouncedFetch);
|
||||
band2m.addEventListener("change", fetchRepeaters);
|
||||
band70cm.addEventListener("change", fetchRepeaters);
|
||||
|
||||
// Initial load
|
||||
fetchRepeaters();
|
||||
})();
|
||||
22
static/index.html
Normal file
22
static/index.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<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>
|
||||
<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" />
|
||||
</head>
|
||||
<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>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
135
static/style.css
Normal file
135
static/style.css
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#controls label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
min-height: 44px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#controls input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#band-2m:checked + span,
|
||||
#controls label:first-child {
|
||||
color: #1976D2;
|
||||
}
|
||||
|
||||
#controls label:last-child {
|
||||
color: #F57C00;
|
||||
}
|
||||
|
||||
#status-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
#status-bar.truncated {
|
||||
background: rgba(255, 193, 7, 0.9);
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rptr-popup h3 {
|
||||
margin: 0 0 6px 0;
|
||||
font-size: 15px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.rptr-popup table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rptr-popup td {
|
||||
padding: 2px 6px;
|
||||
border-bottom: 1px solid #eee;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.rptr-popup td:first-child {
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.rptr-popup .band-tag {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.rptr-popup .band-2m {
|
||||
background: #2196F3;
|
||||
}
|
||||
|
||||
.rptr-popup .band-70cm {
|
||||
background: #FF9800;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#controls {
|
||||
top: 10px;
|
||||
right: 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