package orbitviewer import ( "fmt" "io" "net/http" "strings" "sync" "time" ) // SatelliteCatalogEntry holds metadata and orbital data for a satellite. type SatelliteCatalogEntry struct { ID string `json:"id"` Name string `json:"name"` NoradID int `json:"norad_id"` Description string `json:"description"` StandardMag float64 `json:"standard_mag"` TLE TLE `json:"tle"` UpdatedAt time.Time `json:"updated_at"` } type CatalogManager struct { mu sync.RWMutex satellites map[string]*SatelliteCatalogEntry httpClient *http.Client } var DefaultCatalog = NewCatalogManager() func NewCatalogManager() *CatalogManager { cm := &CatalogManager{ satellites: make(map[string]*SatelliteCatalogEntry), httpClient: &http.Client{Timeout: 8 * time.Second}, } cm.initPresets() // Fetch fresh live TLEs from CelesTrak in background go cm.refreshAllBackground() return cm } func (cm *CatalogManager) refreshAllBackground() { time.Sleep(500 * time.Millisecond) cm.mu.RLock() ids := make([]string, 0, len(cm.satellites)) for id := range cm.satellites { ids = append(ids, id) } cm.mu.RUnlock() for _, id := range ids { _ = cm.RefreshFromCelesTrak(id) time.Sleep(200 * time.Millisecond) // avoid hammering celestrak } } func (cm *CatalogManager) initPresets() { presets := []struct { id string name string noradID int description string standardMag float64 line1 string line2 string }{ { id: "iss", name: "ISS (International Space Station)", noradID: 25544, description: "The largest modular space station currently in low Earth orbit, frequently visible to the naked eye.", standardMag: -1.8, line1: "1 25544U 98067A 26265.50000000 .00014524 00000-0 26388-3 0 9993", line2: "2 25544 51.6416 195.4568 0004928 120.1234 239.9876 15.49876543445566", }, { id: "tiangong", name: "Tiangong (CSS)", noradID: 48274, description: "Chinese space station in low Earth orbit between 340 and 450 km altitude.", standardMag: -1.0, line1: "1 48274U 21035A 26265.45678901 .00021456 00000-0 31456-3 0 9991", line2: "2 48274 41.4723 112.3456 0005123 85.2341 274.9812 15.61234567167890", }, { id: "hubble", name: "Hubble Space Telescope (HST)", noradID: 20580, description: "Famous NASA/ESA optical space observatory operating in orbit since 1990.", standardMag: 1.5, line1: "1 20580U 90037B 26265.34567890 .00001234 00000-0 54321-4 0 9992", line2: "2 20580 28.4691 245.1234 0002845 280.1234 79.8765 15.08765432854321", }, { id: "noaa19", name: "NOAA 19", noradID: 33591, description: "Polar-orbiting operational weather satellite operated by NOAA.", standardMag: 3.5, line1: "1 33591U 09005A 26265.23456789 .00000123 00000-0 65432-4 0 9995", line2: "2 33591 98.7123 145.6789 0014234 65.4321 294.7890 14.12345678789012", }, { id: "terra", name: "Terra (EOS AM-1)", noradID: 25994, description: "NASA flagship Earth-observing satellite in a sun-synchronous orbit.", standardMag: 2.0, line1: "1 25994U 99068A 26265.12345678 .00000234 00000-0 76543-4 0 9998", line2: "2 25994 98.2123 210.3456 0001234 110.1234 250.0123 14.58765432298765", }, { id: "envisat", name: "Envisat", noradID: 27386, description: "Large inactive European Earth observation satellite, bright and easily tracked.", standardMag: 2.0, line1: "1 27386U 02009A 26265.65432109 .00000345 00000-0 87654-4 0 9997", line2: "2 27386 98.5432 78.9012 0001567 95.4321 264.7654 14.37654321154321", }, { id: "starlink", name: "Starlink (Sample)", noradID: 44713, description: "SpaceX Starlink low Earth orbit communications constellation satellite.", standardMag: 4.5, line1: "1 44713U 19074A 26265.51234567 .00001567 00000-0 12345-3 0 9990", line2: "2 44713 53.0543 180.1234 0001456 80.1234 280.0123 15.06543210234567", }, } for _, p := range presets { tle, err := ParseTLE(p.name, p.line1, p.line2) if err == nil { cm.satellites[p.id] = &SatelliteCatalogEntry{ ID: p.id, Name: p.name, NoradID: p.noradID, Description: p.description, StandardMag: p.standardMag, TLE: tle, UpdatedAt: time.Now(), } } } } // GetAll returns all catalog entries sorted. func (cm *CatalogManager) GetAll() []*SatelliteCatalogEntry { cm.mu.RLock() defer cm.mu.RUnlock() result := make([]*SatelliteCatalogEntry, 0, len(cm.satellites)) for _, e := range cm.satellites { result = append(result, e) } return result } // Get returns an entry by ID or NORAD ID string. func (cm *CatalogManager) Get(idOrNorad string) (*SatelliteCatalogEntry, bool) { cm.mu.RLock() defer cm.mu.RUnlock() if e, ok := cm.satellites[idOrNorad]; ok { return e, true } for _, e := range cm.satellites { if fmt.Sprintf("%d", e.NoradID) == idOrNorad { return e, true } } return nil, false } // RefreshFromCelesTrak fetches updated TLE for a given catalog entry by NORAD ID. func (cm *CatalogManager) RefreshFromCelesTrak(id string) error { cm.mu.RLock() entry, ok := cm.satellites[id] cm.mu.RUnlock() if !ok { return fmt.Errorf("satellite ID %s not found in catalog", id) } urls := []string{ fmt.Sprintf("https://celestrak.org/NORAD/elements/gp.php?CATNR=%d&FORMAT=TLE", entry.NoradID), fmt.Sprintf("https://celestrak.com/NORAD/elements/gp.php?CATNR=%d&FORMAT=TLE", entry.NoradID), } var body []byte var lastErr error for _, targetURL := range urls { req, err := http.NewRequest("GET", targetURL, nil) if err != nil { lastErr = err continue } req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36") req.Header.Set("Accept", "text/plain, */*") resp, err := cm.httpClient.Do(req) if err != nil { lastErr = err continue } if resp.StatusCode == http.StatusOK { data, err := io.ReadAll(resp.Body) _ = resp.Body.Close() if err == nil && len(data) > 60 { body = data lastErr = nil break } } else { _ = resp.Body.Close() lastErr = fmt.Errorf("celestrak returned HTTP %d", resp.StatusCode) } } if lastErr != nil && len(body) == 0 { return lastErr } lines := strings.Split(strings.ReplaceAll(string(body), "\r\n", "\n"), "\n") var l1, l2, name string for _, l := range lines { l = strings.TrimSpace(l) if strings.HasPrefix(l, "1 ") && len(l) >= 68 { l1 = l } else if strings.HasPrefix(l, "2 ") && len(l) >= 68 { l2 = l } else if l != "" && !strings.HasPrefix(l, "1 ") && !strings.HasPrefix(l, "2 ") && name == "" { name = l } } if l1 == "" || l2 == "" { return fmt.Errorf("invalid TLE response from CelesTrak for NORAD %d", entry.NoradID) } if name == "" { name = entry.Name } tle, err := ParseTLE(name, l1, l2) if err != nil { return err } cm.mu.Lock() entry.TLE = tle entry.UpdatedAt = time.Now() cm.mu.Unlock() return nil } // AddCustomTLE adds or updates a custom user-supplied TLE into the catalog. func (cm *CatalogManager) AddCustomTLE(name, line1, line2 string) (*SatelliteCatalogEntry, error) { tle, err := ParseTLE(name, line1, line2) if err != nil { return nil, err } id := fmt.Sprintf("custom-%d", tle.NoradID) entry := &SatelliteCatalogEntry{ ID: id, Name: tle.Name, NoradID: tle.NoradID, Description: "User-defined custom satellite orbital parameters", StandardMag: 3.0, TLE: tle, UpdatedAt: time.Now(), } cm.mu.Lock() cm.satellites[id] = entry cm.mu.Unlock() return entry, nil }