diff --git a/db.go b/db.go index e42292e..01e9751 100644 --- a/db.go +++ b/db.go @@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS repeaters ( trustee TEXT NOT NULL DEFAULT '', ipsc_network TEXT NOT NULL DEFAULT '', network TEXT NOT NULL DEFAULT '', + hotspot INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'ACTIVE' ); @@ -49,6 +50,7 @@ type Repeater struct { Trustee string `json:"trustee"` IpscNetwork string `json:"ipsc_network"` Network string `json:"network"` + Hotspot int `json:"hotspot"` Status string `json:"status"` } @@ -64,9 +66,9 @@ func openDB(path string) (*sql.DB, error) { 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, - 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 ?` 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')" } + if !showHotspots { + query += " AND hotspot = 0" + } + // Network filter: only apply when not all 4 categories are selected if len(networks) > 0 && len(networks) < 4 { 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, &r.Lat, &r.Lng, &r.City, &r.State, &r.Country, &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 } 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. -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 { return []Repeater{}, nil } @@ -162,7 +168,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, networks) + candidates, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band, networks, showHotspots) if err != nil { return nil, err } diff --git a/handlers.go b/handlers.go index 7e3929a..8b65294 100644 --- a/handlers.go +++ b/handlers.go @@ -18,6 +18,7 @@ type routeRequest struct { Band string `json:"band"` Corridor float64 `json:"corridor"` Network []string `json:"network"` + Hotspots bool `json:"hotspots"` } func handleRepeaters(db *sql.DB) http.HandlerFunc { @@ -52,7 +53,9 @@ func handleRepeaters(db *sql.DB) http.HandlerFunc { 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 { http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError) return @@ -99,7 +102,7 @@ func handleRouteRepeaters(db *sql.DB) http.HandlerFunc { 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 { http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError) return diff --git a/seed.go b/seed.go index e2af855..eecd2e2 100644 --- a/seed.go +++ b/seed.go @@ -24,6 +24,7 @@ type rawRepeater struct { TsLinked string `json:"ts_linked"` Trustee string `json:"trustee"` IpscNetwork string `json:"ipsc_network"` + MapInfo string `json:"map_info"` Status string `json:"status"` } @@ -42,12 +43,6 @@ 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) @@ -75,15 +70,15 @@ 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, network, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + color_code, offset, ts_linked, trustee, ipsc_network, network, hotspot, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) if err != nil { tx.Rollback() return fmt.Errorf("prepare stmt: %w", err) } defer stmt.Close() - inserted, skipped := 0, 0 + inserted, skipped, hotspots := 0, 0, 0 for _, r := range raw.Rptrs { lat, lng, ok := parseCoords(r.Lat, r.Lng) if !ok { @@ -98,9 +93,14 @@ func seedDatabase(dbPath, jsonPath string) error { band := classifyBand(freq) 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, 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) skipped++ continue @@ -112,7 +112,7 @@ func seedDatabase(dbPath, jsonPath string) error { 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 } @@ -172,6 +172,23 @@ func classifyBand(freq float64) string { 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 { s := strings.ToLower(strings.TrimSpace(raw)) if s == "" || s == "none" || s == "n/a" || s == "?" || s == "-" || s == "no" { diff --git a/static/app.js b/static/app.js index 1a76348..f35515d 100644 --- a/static/app.js +++ b/static/app.js @@ -45,6 +45,7 @@ var netDmrplus = document.getElementById("net-dmrplus"); var netTgif = document.getElementById("net-tgif"); var netOther = document.getElementById("net-other"); + var showHotspots = document.getElementById("show-hotspots"); var countEl = document.getElementById("count"); var fromInput = document.getElementById("route-from"); var toInput = document.getElementById("route-to"); @@ -317,6 +318,7 @@ maxLng: bounds.getEast(), band: getSelectedBand(), network: getSelectedNetworks(), + hotspots: showHotspots.checked ? "1" : "0", }); fetch("/api/repeaters?" + params, { signal: controller.signal }) @@ -397,6 +399,7 @@ band: getSelectedBand(), corridor: 10, network: getSelectedNetworks() === "all" ? [] : getSelectedNetworks().split(","), + hotspots: showHotspots.checked, }), }) .then(function (resp) { @@ -494,6 +497,11 @@ }); }); + showHotspots.addEventListener("change", function () { + if (isRouteMode) fetchRouteRepeaters(); + else fetchRepeaters(); + }); + routeBtn.addEventListener("click", findRoute); clearBtn.addEventListener("click", clearRoute); diff --git a/static/index.html b/static/index.html index 7b4f4e5..c6ab2ed 100644 --- a/static/index.html +++ b/static/index.html @@ -22,6 +22,10 @@ +
+ Experimental + +
diff --git a/static/style.css b/static/style.css index 270cc9d..25133f8 100644 --- a/static/style.css +++ b/static/style.css @@ -157,6 +157,44 @@ html.dark .band-70cm { 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 { margin-left: auto; font-size: 12px;