Add filtering by network
This commit is contained in:
parent
06c34df2c6
commit
7e1781ae13
7 changed files with 160 additions and 21 deletions
|
|
@ -9,7 +9,7 @@ services:
|
|||
- .:/app
|
||||
- go-mod-cache:/go/pkg/mod
|
||||
- go-build-cache:/root/.cache/go-build
|
||||
- repeater-data:/app/data
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- DB_PATH=/app/data/repeaters.db
|
||||
- JSON_PATH=/app/rptrs.json
|
||||
|
|
@ -18,4 +18,3 @@ services:
|
|||
volumes:
|
||||
go-mod-cache:
|
||||
go-build-cache:
|
||||
repeater-data:
|
||||
|
|
|
|||
53
db.go
53
db.go
|
|
@ -3,6 +3,7 @@ package main
|
|||
import (
|
||||
"database/sql"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
|
@ -23,11 +24,13 @@ CREATE TABLE IF NOT EXISTS repeaters (
|
|||
ts_linked TEXT NOT NULL DEFAULT '',
|
||||
trustee TEXT NOT NULL DEFAULT '',
|
||||
ipsc_network TEXT NOT NULL DEFAULT '',
|
||||
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);
|
||||
CREATE INDEX IF NOT EXISTS idx_repeaters_network ON repeaters (network);
|
||||
`
|
||||
|
||||
type Repeater struct {
|
||||
|
|
@ -45,6 +48,7 @@ type Repeater struct {
|
|||
TsLinked string `json:"ts_linked"`
|
||||
Trustee string `json:"trustee"`
|
||||
IpscNetwork string `json:"ipsc_network"`
|
||||
Network string `json:"network"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
|
|
@ -60,22 +64,49 @@ func openDB(path string) (*sql.DB, error) {
|
|||
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
|
||||
func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band string, networks []string) ([]Repeater, error) {
|
||||
query := `SELECT id, callsign, frequency, band, lat, lng, city, state, country,
|
||||
color_code, offset, ts_linked, trustee, ipsc_network, network, status
|
||||
FROM repeaters WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?`
|
||||
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
args := []interface{}{minLat, maxLat, minLng, maxLng}
|
||||
|
||||
switch band {
|
||||
case "2m":
|
||||
rows, err = db.Query(baseQuery+" AND band = ?", minLat, maxLat, minLng, maxLng, "2m")
|
||||
query += " AND band = ?"
|
||||
args = append(args, "2m")
|
||||
case "70cm":
|
||||
rows, err = db.Query(baseQuery+" AND band = ?", minLat, maxLat, minLng, maxLng, "70cm")
|
||||
query += " AND band = ?"
|
||||
args = append(args, "70cm")
|
||||
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 {
|
||||
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,
|
||||
&r.Lat, &r.Lng, &r.City, &r.State, &r.Country,
|
||||
&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
|
||||
}
|
||||
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.
|
||||
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 {
|
||||
return []Repeater{}, nil
|
||||
}
|
||||
|
|
@ -131,7 +162,7 @@ func queryRepeatersAlongRoute(db *sql.DB, points [][2]float64, corridorKm float6
|
|||
maxLng += lngPad
|
||||
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
11
handlers.go
11
handlers.go
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type apiResponse struct {
|
||||
|
|
@ -16,6 +17,7 @@ type routeRequest struct {
|
|||
Points [][2]float64 `json:"points"`
|
||||
Band string `json:"band"`
|
||||
Corridor float64 `json:"corridor"`
|
||||
Network []string `json:"network"`
|
||||
}
|
||||
|
||||
func handleRepeaters(db *sql.DB) http.HandlerFunc {
|
||||
|
|
@ -45,7 +47,12 @@ func handleRepeaters(db *sql.DB) http.HandlerFunc {
|
|||
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 {
|
||||
http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -92,7 +99,7 @@ func handleRouteRepeaters(db *sql.DB) http.HandlerFunc {
|
|||
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 {
|
||||
http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
|
|
|
|||
44
seed.go
44
seed.go
|
|
@ -7,6 +7,7 @@ import (
|
|||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type rawRepeater struct {
|
||||
|
|
@ -41,6 +42,12 @@ func seedDatabase(dbPath, jsonPath string) error {
|
|||
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
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM repeaters").Scan(&count); err != nil {
|
||||
return fmt.Errorf("count check: %w", err)
|
||||
|
|
@ -68,8 +75,8 @@ func seedDatabase(dbPath, jsonPath string) error {
|
|||
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
color_code, offset, ts_linked, trustee, ipsc_network, network, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("prepare stmt: %w", err)
|
||||
|
|
@ -90,9 +97,10 @@ func seedDatabase(dbPath, jsonPath string) error {
|
|||
}
|
||||
band := classifyBand(freq)
|
||||
|
||||
network := classifyNetwork(r.IpscNetwork)
|
||||
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 {
|
||||
r.TsLinked, r.Trustee, r.IpscNetwork, network, r.Status); err != nil {
|
||||
log.Printf("Warning: skipping repeater %d (%s): %v", r.ID, r.Callsign, err)
|
||||
skipped++
|
||||
continue
|
||||
|
|
@ -163,3 +171,33 @@ func classifyBand(freq float64) string {
|
|||
}
|
||||
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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@
|
|||
// === DOM ===
|
||||
var band2m = document.getElementById("band-2m");
|
||||
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 fromInput = document.getElementById("route-from");
|
||||
var toInput = document.getElementById("route-to");
|
||||
|
|
@ -200,6 +204,17 @@
|
|||
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) {
|
||||
if (!str) return "";
|
||||
return str
|
||||
|
|
@ -241,10 +256,10 @@
|
|||
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)
|
||||
if (r.network)
|
||||
html +=
|
||||
"<tr><td>Network</td><td>" +
|
||||
escapeHtml(r.ipsc_network) +
|
||||
escapeHtml(r.network) +
|
||||
"</td></tr>";
|
||||
if (r.trustee)
|
||||
html +=
|
||||
|
|
@ -285,6 +300,12 @@
|
|||
function fetchRepeaters() {
|
||||
if (isRouteMode) return;
|
||||
|
||||
if (getSelectedNetworks() === "none") {
|
||||
markerLayer.clearLayers();
|
||||
showCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (controller) controller.abort();
|
||||
controller = new AbortController();
|
||||
|
||||
|
|
@ -295,6 +316,7 @@
|
|||
minLng: bounds.getWest(),
|
||||
maxLng: bounds.getEast(),
|
||||
band: getSelectedBand(),
|
||||
network: getSelectedNetworks(),
|
||||
});
|
||||
|
||||
fetch("/api/repeaters?" + params, { signal: controller.signal })
|
||||
|
|
@ -360,6 +382,12 @@
|
|||
function fetchRouteRepeaters() {
|
||||
if (!routePoints) return;
|
||||
|
||||
if (getSelectedNetworks() === "none") {
|
||||
markerLayer.clearLayers();
|
||||
showCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus("Loading...");
|
||||
return fetch("/api/repeaters/route", {
|
||||
method: "POST",
|
||||
|
|
@ -368,6 +396,7 @@
|
|||
points: routePoints,
|
||||
band: getSelectedBand(),
|
||||
corridor: 10,
|
||||
network: getSelectedNetworks() === "all" ? [] : getSelectedNetworks().split(","),
|
||||
}),
|
||||
})
|
||||
.then(function (resp) {
|
||||
|
|
@ -458,6 +487,13 @@
|
|||
else fetchRepeaters();
|
||||
});
|
||||
|
||||
[netBm, netDmrplus, netTgif, netOther].forEach(function (cb) {
|
||||
cb.addEventListener("change", function () {
|
||||
if (isRouteMode) fetchRouteRepeaters();
|
||||
else fetchRepeaters();
|
||||
});
|
||||
});
|
||||
|
||||
routeBtn.addEventListener("click", findRoute);
|
||||
clearBtn.addEventListener("click", clearRoute);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@
|
|||
<span id="count" class="count"></span>
|
||||
<button id="theme-toggle" class="theme-toggle" title="Toggle dark mode"></button>
|
||||
</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">
|
||||
<input type="text" id="route-from" placeholder="From address" autocomplete="off" />
|
||||
<ul class="autocomplete-list" id="ac-from"></ul>
|
||||
|
|
|
|||
|
|
@ -129,12 +129,34 @@ html.dark .band-70cm {
|
|||
color: #ef5350;
|
||||
}
|
||||
|
||||
#controls input[type="checkbox"] {
|
||||
#controls .controls-row:first-child input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
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 {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue