Add filtering by network

This commit is contained in:
Marcus Kida 2026-02-08 18:42:48 +01:00
parent 06c34df2c6
commit 7e1781ae13
7 changed files with 160 additions and 21 deletions

View file

@ -9,7 +9,7 @@ services:
- .:/app - .:/app
- go-mod-cache:/go/pkg/mod - go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build - go-build-cache:/root/.cache/go-build
- repeater-data:/app/data - ./data:/app/data
environment: environment:
- DB_PATH=/app/data/repeaters.db - DB_PATH=/app/data/repeaters.db
- JSON_PATH=/app/rptrs.json - JSON_PATH=/app/rptrs.json
@ -18,4 +18,3 @@ services:
volumes: volumes:
go-mod-cache: go-mod-cache:
go-build-cache: go-build-cache:
repeater-data:

53
db.go
View file

@ -3,6 +3,7 @@ package main
import ( import (
"database/sql" "database/sql"
"math" "math"
"strings"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@ -23,11 +24,13 @@ CREATE TABLE IF NOT EXISTS repeaters (
ts_linked TEXT NOT NULL DEFAULT '', ts_linked TEXT NOT NULL DEFAULT '',
trustee TEXT NOT NULL DEFAULT '', trustee TEXT NOT NULL DEFAULT '',
ipsc_network TEXT NOT NULL DEFAULT '', ipsc_network TEXT NOT NULL DEFAULT '',
network TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'ACTIVE' 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_lat_band ON repeaters (lat, band);
CREATE INDEX IF NOT EXISTS idx_repeaters_lng ON repeaters (lng); CREATE INDEX IF NOT EXISTS idx_repeaters_lng ON repeaters (lng);
CREATE INDEX IF NOT EXISTS idx_repeaters_network ON repeaters (network);
` `
type Repeater struct { type Repeater struct {
@ -45,6 +48,7 @@ type Repeater struct {
TsLinked string `json:"ts_linked"` TsLinked string `json:"ts_linked"`
Trustee string `json:"trustee"` Trustee string `json:"trustee"`
IpscNetwork string `json:"ipsc_network"` IpscNetwork string `json:"ipsc_network"`
Network string `json:"network"`
Status string `json:"status"` Status string `json:"status"`
} }
@ -60,22 +64,49 @@ func openDB(path string) (*sql.DB, error) {
return db, nil return db, nil
} }
func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band string) ([]Repeater, error) { func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band string, networks []string) ([]Repeater, error) {
baseQuery := `SELECT id, callsign, frequency, band, lat, lng, city, state, country, query := `SELECT id, callsign, frequency, band, lat, lng, city, state, country,
color_code, offset, ts_linked, trustee, ipsc_network, status color_code, offset, ts_linked, trustee, ipsc_network, network, status
FROM repeaters WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?` FROM repeaters WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?`
var rows *sql.Rows args := []interface{}{minLat, maxLat, minLng, maxLng}
var err error
switch band { switch band {
case "2m": case "2m":
rows, err = db.Query(baseQuery+" AND band = ?", minLat, maxLat, minLng, maxLng, "2m") query += " AND band = ?"
args = append(args, "2m")
case "70cm": case "70cm":
rows, err = db.Query(baseQuery+" AND band = ?", minLat, maxLat, minLng, maxLng, "70cm") query += " AND band = ?"
args = append(args, "70cm")
default: default:
rows, err = db.Query(baseQuery+" AND band IN ('2m', '70cm')", minLat, maxLat, minLng, maxLng) query += " AND band IN ('2m', '70cm')"
} }
// Network filter: only apply when not all 4 categories are selected
if len(networks) > 0 && len(networks) < 4 {
var placeholders []string
for _, n := range networks {
switch n {
case "BM":
placeholders = append(placeholders, "?")
args = append(args, "Brandmeister")
case "DMR+":
placeholders = append(placeholders, "?")
args = append(args, "DMR+")
case "TGIF":
placeholders = append(placeholders, "?")
args = append(args, "TGIF")
case "Other":
placeholders = append(placeholders, "?", "?", "?", "?")
args = append(args, "DMR-MARC", "FreeDMR", "Other", "")
}
}
if len(placeholders) > 0 {
query += " AND network IN (" + strings.Join(placeholders, ",") + ")"
}
}
rows, err := db.Query(query, args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -87,7 +118,7 @@ func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band str
if err := rows.Scan(&r.ID, &r.Callsign, &r.Frequency, &r.Band, if err := rows.Scan(&r.ID, &r.Callsign, &r.Frequency, &r.Band,
&r.Lat, &r.Lng, &r.City, &r.State, &r.Country, &r.Lat, &r.Lng, &r.City, &r.State, &r.Country,
&r.ColorCode, &r.Offset, &r.TsLinked, &r.Trustee, &r.ColorCode, &r.Offset, &r.TsLinked, &r.Trustee,
&r.IpscNetwork, &r.Status); err != nil { &r.IpscNetwork, &r.Network, &r.Status); err != nil {
return nil, err return nil, err
} }
results = append(results, r) results = append(results, r)
@ -100,7 +131,7 @@ func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band str
} }
// Route corridor query: find repeaters within corridorKm of a polyline. // Route corridor query: find repeaters within corridorKm of a polyline.
func queryRepeatersAlongRoute(db *sql.DB, points [][2]float64, corridorKm float64, band string) ([]Repeater, error) { func queryRepeatersAlongRoute(db *sql.DB, points [][2]float64, corridorKm float64, band string, networks []string) ([]Repeater, error) {
if len(points) == 0 { if len(points) == 0 {
return []Repeater{}, nil return []Repeater{}, nil
} }
@ -131,7 +162,7 @@ func queryRepeatersAlongRoute(db *sql.DB, points [][2]float64, corridorKm float6
maxLng += lngPad maxLng += lngPad
// Fetch candidates from bounding box // Fetch candidates from bounding box
candidates, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band) candidates, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band, networks)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"strings"
) )
type apiResponse struct { type apiResponse struct {
@ -16,6 +17,7 @@ type routeRequest struct {
Points [][2]float64 `json:"points"` Points [][2]float64 `json:"points"`
Band string `json:"band"` Band string `json:"band"`
Corridor float64 `json:"corridor"` Corridor float64 `json:"corridor"`
Network []string `json:"network"`
} }
func handleRepeaters(db *sql.DB) http.HandlerFunc { func handleRepeaters(db *sql.DB) http.HandlerFunc {
@ -45,7 +47,12 @@ func handleRepeaters(db *sql.DB) http.HandlerFunc {
return return
} }
repeaters, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band) var networks []string
if net := q.Get("network"); net != "" && net != "all" {
networks = strings.Split(net, ",")
}
repeaters, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band, networks)
if err != nil { if err != nil {
http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError) http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError)
return return
@ -92,7 +99,7 @@ func handleRouteRepeaters(db *sql.DB) http.HandlerFunc {
req.Corridor = 10 req.Corridor = 10
} }
repeaters, err := queryRepeatersAlongRoute(db, req.Points, req.Corridor, req.Band) repeaters, err := queryRepeatersAlongRoute(db, req.Points, req.Corridor, req.Band, req.Network)
if err != nil { if err != nil {
http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError) http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError)
return return

44
seed.go
View file

@ -7,6 +7,7 @@ import (
"math" "math"
"os" "os"
"strconv" "strconv"
"strings"
) )
type rawRepeater struct { type rawRepeater struct {
@ -41,6 +42,12 @@ func seedDatabase(dbPath, jsonPath string) error {
return fmt.Errorf("create schema: %w", err) return fmt.Errorf("create schema: %w", err)
} }
// Migration: add network column for existing databases
if _, err := db.Exec("ALTER TABLE repeaters ADD COLUMN network TEXT NOT NULL DEFAULT ''"); err == nil {
db.Exec("DELETE FROM repeaters")
log.Println("Added network column, re-seeding database")
}
var count int var count int
if err := db.QueryRow("SELECT COUNT(*) FROM repeaters").Scan(&count); err != nil { if err := db.QueryRow("SELECT COUNT(*) FROM repeaters").Scan(&count); err != nil {
return fmt.Errorf("count check: %w", err) return fmt.Errorf("count check: %w", err)
@ -68,8 +75,8 @@ func seedDatabase(dbPath, jsonPath string) error {
stmt, err := tx.Prepare(`INSERT INTO repeaters stmt, err := tx.Prepare(`INSERT INTO repeaters
(id, callsign, frequency, band, lat, lng, city, state, country, (id, callsign, frequency, band, lat, lng, city, state, country,
color_code, offset, ts_linked, trustee, ipsc_network, status) color_code, offset, ts_linked, trustee, ipsc_network, network, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
return fmt.Errorf("prepare stmt: %w", err) return fmt.Errorf("prepare stmt: %w", err)
@ -90,9 +97,10 @@ func seedDatabase(dbPath, jsonPath string) error {
} }
band := classifyBand(freq) band := classifyBand(freq)
network := classifyNetwork(r.IpscNetwork)
if _, err := stmt.Exec(r.ID, r.Callsign, freq, band, lat, lng, if _, err := stmt.Exec(r.ID, r.Callsign, freq, band, lat, lng,
r.City, r.State, r.Country, r.ColorCode, r.Offset, r.City, r.State, r.Country, r.ColorCode, r.Offset,
r.TsLinked, r.Trustee, r.IpscNetwork, r.Status); err != nil { r.TsLinked, r.Trustee, r.IpscNetwork, network, r.Status); err != nil {
log.Printf("Warning: skipping repeater %d (%s): %v", r.ID, r.Callsign, err) log.Printf("Warning: skipping repeater %d (%s): %v", r.ID, r.Callsign, err)
skipped++ skipped++
continue continue
@ -163,3 +171,33 @@ func classifyBand(freq float64) string {
} }
return "other" return "other"
} }
func classifyNetwork(raw string) string {
s := strings.ToLower(strings.TrimSpace(raw))
if s == "" || s == "none" || s == "n/a" || s == "?" || s == "-" || s == "no" {
return ""
}
if strings.Contains(s, "brandm") || s == "bm" || strings.HasPrefix(s, "bm ") ||
strings.HasPrefix(s, "bm,") || strings.HasPrefix(s, "bm/") ||
strings.Contains(s, "brand m") || strings.Contains(s, "braindm") ||
strings.Contains(s, "branme") || strings.Contains(s, "bramm") ||
strings.Contains(s, "brewmister") || s == "bmr" {
return "Brandmeister"
}
if strings.Contains(s, "dmr-plus") || strings.Contains(s, "dmr+") ||
strings.Contains(s, "dmrplus") || s == "dmr plus" ||
strings.HasPrefix(s, "ipsc2") || s == "ipsc" {
return "DMR+"
}
if strings.Contains(s, "dmr-marc") || strings.Contains(s, "dmr marc") ||
s == "marc" || strings.Contains(s, "dmrmarc") {
return "DMR-MARC"
}
if strings.Contains(s, "tgif") {
return "TGIF"
}
if strings.Contains(s, "freedmr") || strings.Contains(s, "free-dmr") || strings.Contains(s, "free dmr") {
return "FreeDMR"
}
return "Other"
}

View file

@ -41,6 +41,10 @@
// === DOM === // === DOM ===
var band2m = document.getElementById("band-2m"); var band2m = document.getElementById("band-2m");
var band70cm = document.getElementById("band-70cm"); var band70cm = document.getElementById("band-70cm");
var netBm = document.getElementById("net-bm");
var netDmrplus = document.getElementById("net-dmrplus");
var netTgif = document.getElementById("net-tgif");
var netOther = document.getElementById("net-other");
var countEl = document.getElementById("count"); var countEl = document.getElementById("count");
var fromInput = document.getElementById("route-from"); var fromInput = document.getElementById("route-from");
var toInput = document.getElementById("route-to"); var toInput = document.getElementById("route-to");
@ -200,6 +204,17 @@
return "all"; return "all";
} }
function getSelectedNetworks() {
var nets = [];
if (netBm.checked) nets.push("BM");
if (netDmrplus.checked) nets.push("DMR+");
if (netTgif.checked) nets.push("TGIF");
if (netOther.checked) nets.push("Other");
if (nets.length === 4) return "all";
if (nets.length === 0) return "none";
return nets.join(",");
}
function escapeHtml(str) { function escapeHtml(str) {
if (!str) return ""; if (!str) return "";
return str return str
@ -241,10 +256,10 @@
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);
html += "<tr><td>Location</td><td>" + loc + "</td></tr>"; html += "<tr><td>Location</td><td>" + loc + "</td></tr>";
if (r.ipsc_network) if (r.network)
html += html +=
"<tr><td>Network</td><td>" + "<tr><td>Network</td><td>" +
escapeHtml(r.ipsc_network) + escapeHtml(r.network) +
"</td></tr>"; "</td></tr>";
if (r.trustee) if (r.trustee)
html += html +=
@ -285,6 +300,12 @@
function fetchRepeaters() { function fetchRepeaters() {
if (isRouteMode) return; if (isRouteMode) return;
if (getSelectedNetworks() === "none") {
markerLayer.clearLayers();
showCount(0);
return;
}
if (controller) controller.abort(); if (controller) controller.abort();
controller = new AbortController(); controller = new AbortController();
@ -295,6 +316,7 @@
minLng: bounds.getWest(), minLng: bounds.getWest(),
maxLng: bounds.getEast(), maxLng: bounds.getEast(),
band: getSelectedBand(), band: getSelectedBand(),
network: getSelectedNetworks(),
}); });
fetch("/api/repeaters?" + params, { signal: controller.signal }) fetch("/api/repeaters?" + params, { signal: controller.signal })
@ -360,6 +382,12 @@
function fetchRouteRepeaters() { function fetchRouteRepeaters() {
if (!routePoints) return; if (!routePoints) return;
if (getSelectedNetworks() === "none") {
markerLayer.clearLayers();
showCount(0);
return;
}
showStatus("Loading..."); showStatus("Loading...");
return fetch("/api/repeaters/route", { return fetch("/api/repeaters/route", {
method: "POST", method: "POST",
@ -368,6 +396,7 @@
points: routePoints, points: routePoints,
band: getSelectedBand(), band: getSelectedBand(),
corridor: 10, corridor: 10,
network: getSelectedNetworks() === "all" ? [] : getSelectedNetworks().split(","),
}), }),
}) })
.then(function (resp) { .then(function (resp) {
@ -458,6 +487,13 @@
else fetchRepeaters(); else fetchRepeaters();
}); });
[netBm, netDmrplus, netTgif, netOther].forEach(function (cb) {
cb.addEventListener("change", function () {
if (isRouteMode) fetchRouteRepeaters();
else fetchRepeaters();
});
});
routeBtn.addEventListener("click", findRoute); routeBtn.addEventListener("click", findRoute);
clearBtn.addEventListener("click", clearRoute); clearBtn.addEventListener("click", clearRoute);

View file

@ -16,6 +16,12 @@
<span id="count" class="count"></span> <span id="count" class="count"></span>
<button id="theme-toggle" class="theme-toggle" title="Toggle dark mode"></button> <button id="theme-toggle" class="theme-toggle" title="Toggle dark mode"></button>
</div> </div>
<div class="controls-row network-row">
<label class="net-label net-bm"><input type="checkbox" id="net-bm" checked> BM</label>
<label class="net-label net-dmrplus"><input type="checkbox" id="net-dmrplus" checked> DMR+</label>
<label class="net-label net-tgif"><input type="checkbox" id="net-tgif" checked> TGIF</label>
<label class="net-label net-other"><input type="checkbox" id="net-other" checked> Other</label>
</div>
<div class="controls-row route-row autocomplete-wrap"> <div class="controls-row route-row autocomplete-wrap">
<input type="text" id="route-from" placeholder="From address" autocomplete="off" /> <input type="text" id="route-from" placeholder="From address" autocomplete="off" />
<ul class="autocomplete-list" id="ac-from"></ul> <ul class="autocomplete-list" id="ac-from"></ul>

View file

@ -129,12 +129,34 @@ html.dark .band-70cm {
color: #ef5350; color: #ef5350;
} }
#controls input[type="checkbox"] { #controls .controls-row:first-child input[type="checkbox"] {
width: 18px; width: 18px;
height: 18px; height: 18px;
cursor: pointer; cursor: pointer;
} }
.network-row {
margin-top: 4px;
}
.net-label {
display: flex;
align-items: center;
gap: 3px;
cursor: pointer;
font-size: 11px;
font-weight: 500;
min-height: 28px;
user-select: none;
color: var(--text-muted);
}
.net-label input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
}
.count { .count {
margin-left: auto; margin-left: auto;
font-size: 12px; font-size: 12px;