Add flag to hide potentially personal hotspots
This commit is contained in:
parent
0d79baee90
commit
5d3ab6a071
6 changed files with 94 additions and 18 deletions
16
db.go
16
db.go
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
39
seed.go
|
|
@ -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" {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue