package main import ( "database/sql" "fmt" "log" "math" "sort" "strings" "time" _ "github.com/jackc/pgx/v5/stdlib" ) type Repeater struct { ID int `json:"id"` Callsign string `json:"callsign"` FreqTx float64 `json:"freq_tx"` FreqRx float64 `json:"freq_rx"` FreqOffset string `json:"freq_offset"` Band string `json:"band"` Lat float64 `json:"lat"` Lng float64 `json:"lng"` City string `json:"city"` State string `json:"state"` Country string `json:"country"` ColorCode int `json:"color_code"` TsLinked string `json:"ts_linked"` Trustee string `json:"trustee"` IpscNetwork string `json:"ipsc_network"` Network string `json:"network"` Hotspot int `json:"hotspot"` Status string `json:"status"` LastSeen *time.Time `json:"last_seen"` BmStatus *int `json:"bm_status"` BmStatusText string `json:"bm_status_text"` Hardware string `json:"hardware"` Firmware string `json:"firmware"` Pep int `json:"pep"` Agl int `json:"agl"` Website string `json:"website"` Description string `json:"description"` ImportFreqInconsistent bool `json:"import_freq_inconsistent"` Inactive bool `json:"inactive"` } func openDB(dsn string) (*sql.DB, error) { var db *sql.DB var err error for i := 0; i < 30; i++ { db, err = sql.Open("pgx", dsn) if err != nil { log.Printf("Failed to open database (attempt %d/30): %v", i+1, err) time.Sleep(time.Second) continue } if err = db.Ping(); err != nil { db.Close() log.Printf("Failed to ping database (attempt %d/30): %v", i+1, err) time.Sleep(time.Second) continue } break } if err != nil { return nil, fmt.Errorf("failed to connect to database after 30 attempts: %w", err) } db.SetMaxOpenConns(25) db.SetMaxIdleConns(5) db.SetConnMaxLifetime(5 * time.Minute) return db, nil } func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band string, networks []string, showHotspots bool, showInactive bool) ([]Repeater, error) { paramIdx := 0 nextParam := func() string { paramIdx++ return fmt.Sprintf("$%d", paramIdx) } query := `SELECT id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country, color_code, ts_linked, trustee, ipsc_network, network, hotspot, status, last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description, import_freq_inconsistent FROM repeaters WHERE lat BETWEEN ` + nextParam() + ` AND ` + nextParam() + ` AND lng BETWEEN ` + nextParam() + ` AND ` + nextParam() args := []interface{}{minLat, maxLat, minLng, maxLng} switch band { case "2m": query += " AND band = " + nextParam() args = append(args, "2m") case "70cm": query += " AND band = " + nextParam() args = append(args, "70cm") default: 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 for _, n := range networks { switch n { case "BM": placeholders = append(placeholders, nextParam()) args = append(args, "Brandmeister") case "DMR+": placeholders = append(placeholders, nextParam()) args = append(args, "DMR+") case "TGIF": placeholders = append(placeholders, nextParam()) args = append(args, "TGIF") case "Other": placeholders = append(placeholders, nextParam(), nextParam(), nextParam(), nextParam()) args = append(args, "DMR-MARC", "FreeDMR", "Other", "") } } if len(placeholders) > 0 { query += " AND network IN (" + strings.Join(placeholders, ",") + ")" } } if !showInactive { threshold := time.Now().Add(-7 * 24 * time.Hour) query += " AND (last_seen IS NULL OR last_seen >= " + nextParam() + ")" args = append(args, threshold) } rows, err := db.Query(query, args...) if err != nil { return nil, err } defer rows.Close() threshold := time.Now().Add(-7 * 24 * time.Hour) var results []Repeater for rows.Next() { var r Repeater if err := rows.Scan(&r.ID, &r.Callsign, &r.FreqTx, &r.FreqRx, &r.FreqOffset, &r.Band, &r.Lat, &r.Lng, &r.City, &r.State, &r.Country, &r.ColorCode, &r.TsLinked, &r.Trustee, &r.IpscNetwork, &r.Network, &r.Hotspot, &r.Status, &r.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware, &r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description, &r.ImportFreqInconsistent); err != nil { return nil, err } r.Inactive = r.LastSeen != nil && r.LastSeen.Before(threshold) results = append(results, r) } if err := rows.Err(); err != nil { return nil, err } return results, nil } // Route corridor query: find repeaters within corridorKm of a polyline. func queryRepeatersAlongRoute(db *sql.DB, points [][2]float64, corridorKm float64, band string, networks []string, showHotspots bool, showInactive bool) ([]Repeater, error) { if len(points) == 0 { return []Repeater{}, nil } // Compute bounding box of all route points + corridor padding minLat, maxLat := points[0][0], points[0][0] minLng, maxLng := points[0][1], points[0][1] for _, p := range points { if p[0] < minLat { minLat = p[0] } if p[0] > maxLat { maxLat = p[0] } if p[1] < minLng { minLng = p[1] } if p[1] > maxLng { maxLng = p[1] } } latPad := corridorKm / 111.32 avgLat := (minLat + maxLat) / 2 lngPad := corridorKm / (111.32 * math.Cos(avgLat*math.Pi/180)) minLat -= latPad maxLat += latPad minLng -= lngPad maxLng += lngPad // Fetch candidates from bounding box candidates, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band, networks, showHotspots, showInactive) if err != nil { return nil, err } // Filter by distance to route segments var results []Repeater for _, r := range candidates { if minDistToRoute(r.Lat, r.Lng, points) <= corridorKm { results = append(results, r) } } return results, nil } // Radius query: find repeaters within radiusKm of a point, sorted by distance. type RepeaterWithDistance struct { Repeater Distance float64 `json:"distance"` } func queryRepeatersInRadius(db *sql.DB, lat, lng, radiusKm float64, band string, networks []string, showHotspots bool, showInactive bool) ([]RepeaterWithDistance, error) { latPad := radiusKm / 111.32 lngPad := radiusKm / (111.32 * math.Cos(lat*math.Pi/180)) candidates, err := queryRepeaters(db, lat-latPad, lat+latPad, lng-lngPad, lng+lngPad, band, networks, showHotspots, showInactive) if err != nil { return nil, err } var results []RepeaterWithDistance for _, r := range candidates { d := haversineKm(lat, lng, r.Lat, r.Lng) if d <= radiusKm { results = append(results, RepeaterWithDistance{Repeater: r, Distance: math.Round(d*10) / 10}) } } sort.Slice(results, func(i, j int) bool { return results[i].Distance < results[j].Distance }) return results, nil } func minDistToRoute(lat, lng float64, points [][2]float64) float64 { best := math.Inf(1) for i := 0; i < len(points)-1; i++ { d := distToSegmentKm(lat, lng, points[i][0], points[i][1], points[i+1][0], points[i+1][1]) if d < best { best = d } } if len(points) == 1 { best = haversineKm(lat, lng, points[0][0], points[0][1]) } return best } // Approximate distance from point P to line segment AB in km. func distToSegmentKm(pLat, pLng, aLat, aLng, bLat, bLng float64) float64 { cosLat := math.Cos(pLat * math.Pi / 180) // Project to approximate planar coordinates (km) px := (pLng - aLng) * cosLat * 111.32 py := (pLat - aLat) * 111.32 bx := (bLng - aLng) * cosLat * 111.32 by := (bLat - aLat) * 111.32 lenSq := bx*bx + by*by if lenSq == 0 { return math.Sqrt(px*px + py*py) } t := (px*bx + py*by) / lenSq if t < 0 { t = 0 } if t > 1 { t = 1 } dx := px - t*bx dy := py - t*by return math.Sqrt(dx*dx + dy*dy) } func haversineKm(lat1, lng1, lat2, lng2 float64) float64 { const r = 6371.0 dLat := (lat2 - lat1) * math.Pi / 180 dLng := (lng2 - lng1) * math.Pi / 180 a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)* math.Sin(dLng/2)*math.Sin(dLng/2) return r * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) }