Add flag to hide potentially personal hotspots

This commit is contained in:
Marcus Kida 2026-02-08 19:54:21 +01:00
parent 0d79baee90
commit 5d3ab6a071
6 changed files with 94 additions and 18 deletions

16
db.go
View file

@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS repeaters (
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 '', network TEXT NOT NULL DEFAULT '',
hotspot INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'ACTIVE' status TEXT NOT NULL DEFAULT 'ACTIVE'
); );
@ -49,6 +50,7 @@ type Repeater struct {
Trustee string `json:"trustee"` Trustee string `json:"trustee"`
IpscNetwork string `json:"ipsc_network"` IpscNetwork string `json:"ipsc_network"`
Network string `json:"network"` Network string `json:"network"`
Hotspot int `json:"hotspot"`
Status string `json:"status"` Status string `json:"status"`
} }
@ -64,9 +66,9 @@ 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, networks []string) ([]Repeater, error) { func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band string, networks []string, showHotspots bool) ([]Repeater, error) {
query := `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, network, status color_code, offset, ts_linked, trustee, ipsc_network, network, hotspot, status
FROM repeaters WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?` FROM repeaters WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?`
args := []interface{}{minLat, maxLat, minLng, maxLng} args := []interface{}{minLat, maxLat, minLng, maxLng}
@ -82,6 +84,10 @@ func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band str
query += " AND band IN ('2m', '70cm')" query += " AND band IN ('2m', '70cm')"
} }
if !showHotspots {
query += " AND hotspot = 0"
}
// Network filter: only apply when not all 4 categories are selected // Network filter: only apply when not all 4 categories are selected
if len(networks) > 0 && len(networks) < 4 { if len(networks) > 0 && len(networks) < 4 {
var placeholders []string var placeholders []string
@ -118,7 +124,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.Network, &r.Status); err != nil { &r.IpscNetwork, &r.Network, &r.Hotspot, &r.Status); err != nil {
return nil, err return nil, err
} }
results = append(results, r) results = append(results, r)
@ -131,7 +137,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, networks []string) ([]Repeater, error) { func queryRepeatersAlongRoute(db *sql.DB, points [][2]float64, corridorKm float64, band string, networks []string, showHotspots bool) ([]Repeater, error) {
if len(points) == 0 { if len(points) == 0 {
return []Repeater{}, nil return []Repeater{}, nil
} }
@ -162,7 +168,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, networks) candidates, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band, networks, showHotspots)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -18,6 +18,7 @@ type routeRequest struct {
Band string `json:"band"` Band string `json:"band"`
Corridor float64 `json:"corridor"` Corridor float64 `json:"corridor"`
Network []string `json:"network"` Network []string `json:"network"`
Hotspots bool `json:"hotspots"`
} }
func handleRepeaters(db *sql.DB) http.HandlerFunc { func handleRepeaters(db *sql.DB) http.HandlerFunc {
@ -52,7 +53,9 @@ func handleRepeaters(db *sql.DB) http.HandlerFunc {
networks = strings.Split(net, ",") networks = strings.Split(net, ",")
} }
repeaters, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band, networks) showHotspots := q.Get("hotspots") == "1"
repeaters, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band, networks, showHotspots)
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
@ -99,7 +102,7 @@ func handleRouteRepeaters(db *sql.DB) http.HandlerFunc {
req.Corridor = 10 req.Corridor = 10
} }
repeaters, err := queryRepeatersAlongRoute(db, req.Points, req.Corridor, req.Band, req.Network) repeaters, err := queryRepeatersAlongRoute(db, req.Points, req.Corridor, req.Band, req.Network, req.Hotspots)
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

39
seed.go
View file

@ -24,6 +24,7 @@ type rawRepeater 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"`
MapInfo string `json:"map_info"`
Status string `json:"status"` Status string `json:"status"`
} }
@ -42,12 +43,6 @@ 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)
@ -75,15 +70,15 @@ 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, network, status) color_code, offset, ts_linked, trustee, ipsc_network, network, hotspot, 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)
} }
defer stmt.Close() defer stmt.Close()
inserted, skipped := 0, 0 inserted, skipped, hotspots := 0, 0, 0
for _, r := range raw.Rptrs { for _, r := range raw.Rptrs {
lat, lng, ok := parseCoords(r.Lat, r.Lng) lat, lng, ok := parseCoords(r.Lat, r.Lng)
if !ok { if !ok {
@ -98,9 +93,14 @@ func seedDatabase(dbPath, jsonPath string) error {
band := classifyBand(freq) band := classifyBand(freq)
network := classifyNetwork(r.IpscNetwork) network := classifyNetwork(r.IpscNetwork)
hotspot := 0
if isHotspot(r.Offset, r.City, r.MapInfo) {
hotspot = 1
hotspots++
}
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, network, r.Status); err != nil { r.TsLinked, r.Trustee, r.IpscNetwork, network, hotspot, 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
@ -112,7 +112,7 @@ func seedDatabase(dbPath, jsonPath string) error {
return fmt.Errorf("commit: %w", err) return fmt.Errorf("commit: %w", err)
} }
log.Printf("Seeded %d repeaters (%d skipped)", inserted, skipped) log.Printf("Seeded %d repeaters (%d skipped, %d hotspots)", inserted, skipped, hotspots)
return nil return nil
} }
@ -172,6 +172,23 @@ func classifyBand(freq float64) string {
return "other" return "other"
} }
func isHotspot(offset, city, mapInfo string) bool {
// Zero offset indicates simplex (likely a personal hotspot)
if off, err := strconv.ParseFloat(strings.TrimSpace(offset), 64); err == nil && math.Abs(off) < 0.01 {
return true
}
// Hotspot keywords in city or map_info
check := strings.ToLower(city) + " " + strings.ToLower(mapInfo)
if strings.Contains(check, "hotspot") ||
strings.Contains(check, "pistar") ||
strings.Contains(check, "pi-star") ||
strings.Contains(check, "mmdvm") ||
strings.Contains(check, "simplex") {
return true
}
return false
}
func classifyNetwork(raw string) string { func classifyNetwork(raw string) string {
s := strings.ToLower(strings.TrimSpace(raw)) s := strings.ToLower(strings.TrimSpace(raw))
if s == "" || s == "none" || s == "n/a" || s == "?" || s == "-" || s == "no" { if s == "" || s == "none" || s == "n/a" || s == "?" || s == "-" || s == "no" {

View file

@ -45,6 +45,7 @@
var netDmrplus = document.getElementById("net-dmrplus"); var netDmrplus = document.getElementById("net-dmrplus");
var netTgif = document.getElementById("net-tgif"); var netTgif = document.getElementById("net-tgif");
var netOther = document.getElementById("net-other"); var netOther = document.getElementById("net-other");
var showHotspots = document.getElementById("show-hotspots");
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");
@ -317,6 +318,7 @@
maxLng: bounds.getEast(), maxLng: bounds.getEast(),
band: getSelectedBand(), band: getSelectedBand(),
network: getSelectedNetworks(), network: getSelectedNetworks(),
hotspots: showHotspots.checked ? "1" : "0",
}); });
fetch("/api/repeaters?" + params, { signal: controller.signal }) fetch("/api/repeaters?" + params, { signal: controller.signal })
@ -397,6 +399,7 @@
band: getSelectedBand(), band: getSelectedBand(),
corridor: 10, corridor: 10,
network: getSelectedNetworks() === "all" ? [] : getSelectedNetworks().split(","), network: getSelectedNetworks() === "all" ? [] : getSelectedNetworks().split(","),
hotspots: showHotspots.checked,
}), }),
}) })
.then(function (resp) { .then(function (resp) {
@ -494,6 +497,11 @@
}); });
}); });
showHotspots.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

@ -22,6 +22,10 @@
<label class="net-label net-tgif"><input type="checkbox" id="net-tgif" checked> TGIF</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> <label class="net-label net-other"><input type="checkbox" id="net-other" checked> Other</label>
</div> </div>
<details class="experimental">
<summary>Experimental</summary>
<label class="hotspot-label"><input type="checkbox" id="show-hotspots"> Don't hide personal hotspots</label>
</details>
<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

@ -157,6 +157,44 @@ html.dark .band-70cm {
cursor: pointer; cursor: pointer;
} }
.experimental {
margin-top: 4px;
font-size: 11px;
color: var(--text-fainter);
}
.experimental summary {
cursor: pointer;
user-select: none;
font-size: 10px;
letter-spacing: 0.03em;
}
.experimental summary:hover {
color: var(--text-faint);
}
.experimental[open] {
padding-bottom: 2px;
}
.hotspot-label {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
font-size: 11px;
color: var(--text-faint);
margin-top: 4px;
user-select: none;
}
.hotspot-label input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
}
.count { .count {
margin-left: auto; margin-left: auto;
font-size: 12px; font-size: 12px;