Initial Commit

This commit is contained in:
Marcus Kida 2026-02-08 14:09:14 +01:00
commit 84b14c26cc
17 changed files with 941 additions and 0 deletions

11
.air.toml Normal file
View 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
View file

@ -0,0 +1,3 @@
tmp/
data/
repeaterroute

14
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

165
seed.go Normal file
View 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
View 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:
'&copy; <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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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
View 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
View 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;
}
}