package orbitviewer import ( "fmt" "math" "net/http" "sort" "time" "github.com/gin-gonic/gin" ) type TrackPoint struct { Timestamp int64 `json:"timestamp"` // Unix timestamp in seconds Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` Altitude float64 `json:"altitude"` Azimuth *float64 `json:"azimuth,omitempty"` Elevation *float64 `json:"elevation,omitempty"` } type TrackResponse struct { SatelliteID string `json:"satellite_id"` SatelliteName string `json:"satellite_name"` NoradID int `json:"norad_id"` CurrentTime time.Time `json:"current_time"` Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` AltitudeKm float64 `json:"altitude_km"` VelocityKmS float64 `json:"velocity_km_s"` FootprintKm float64 `json:"footprint_km"` IsSunlit bool `json:"is_sunlit"` SubSolarPoint Geodetic `json:"sub_solar_point"` PastTrack []TrackPoint `json:"past_track"` FutureTrack []TrackPoint `json:"future_track"` OrbitalRing []TrackPoint `json:"orbital_ring,omitempty"` ObserverLook *LookAngles `json:"observer_look,omitempty"` } type TrackRequest struct { SatelliteID string `json:"satellite_id"` CustomTLE *TLEInput `json:"custom_tle,omitempty"` Observer *Geodetic `json:"observer,omitempty"` Timestamp *string `json:"timestamp,omitempty"` } type TLEInput struct { Name string `json:"name"` Line1 string `json:"line1"` Line2 string `json:"line2"` } type PassesRequest struct { SatelliteID string `json:"satellite_id"` CustomTLE *TLEInput `json:"custom_tle,omitempty"` Observer Geodetic `json:"observer"` Days int `json:"days"` MinElevationDeg float64 `json:"min_elevation_deg"` VisibleOnly bool `json:"visible_only"` } type PositionRequest struct { SatelliteID string `json:"satellite_id"` CustomTLE *TLEInput `json:"custom_tle,omitempty"` Observer Geodetic `json:"observer"` Timestamp *string `json:"timestamp,omitempty"` } func getSGP4Model(satID string, custom *TLEInput) (*SGP4Model, *SatelliteCatalogEntry, error) { if custom != nil && custom.Line1 != "" && custom.Line2 != "" { tle, err := ParseTLE(custom.Name, custom.Line1, custom.Line2) if err != nil { return nil, nil, fmt.Errorf("invalid custom TLE: %w", err) } model, err := NewSGP4Model(tle) if err != nil { return nil, nil, err } return model, &SatelliteCatalogEntry{ ID: "custom", Name: tle.Name, NoradID: tle.NoradID, StandardMag: 3.0, TLE: tle, }, nil } if satID == "" { satID = "iss" } entry, ok := DefaultCatalog.Get(satID) if !ok { return nil, nil, fmt.Errorf("satellite %s not found in catalog", satID) } model, err := NewSGP4Model(entry.TLE) if err != nil { return nil, nil, err } return model, entry, nil } func RegisterRoutes(r *gin.RouterGroup) { // List satellites r.GET("/satellites", func(c *gin.Context) { entries := DefaultCatalog.GetAll() sort.Slice(entries, func(i, j int) bool { return entries[i].NoradID < entries[j].NoradID }) c.JSON(http.StatusOK, gin.H{"satellites": entries}) }) // Refresh satellite TLE from CelesTrak r.POST("/refresh", func(c *gin.Context) { var req struct { SatelliteID string `json:"satellite_id"` } if err := c.ShouldBindJSON(&req); err != nil || req.SatelliteID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "satellite_id is required"}) return } if err := DefaultCatalog.RefreshFromCelesTrak(req.SatelliteID); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } entry, _ := DefaultCatalog.Get(req.SatelliteID) c.JSON(http.StatusOK, gin.H{"message": "refreshed successfully", "satellite": entry}) }) // Add custom TLE r.POST("/custom", func(c *gin.Context) { var req TLEInput if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } entry, err := DefaultCatalog.AddCustomTLE(req.Name, req.Line1, req.Line2) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"satellite": entry}) }) // Ground track and current telemetry r.POST("/track", func(c *gin.Context) { var req TrackRequest if err := c.ShouldBindJSON(&req); err != nil { // default to ISS req.SatelliteID = "iss" } model, entry, err := getSGP4Model(req.SatelliteID, req.CustomTLE) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } t := time.Now().UTC() if req.Timestamp != nil && *req.Timestamp != "" { if parsed, err := time.Parse(time.RFC3339, *req.Timestamp); err == nil { t = parsed.UTC() } } geo, vel, err := model.GeodeticPosition(t) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } posECEF, _, _ := model.PositionECEF(t) jd := JulianDate(t) isSunlit := IsSatelliteIlluminated(posECEF, jd) velKmS := math.Sqrt(vel.X*vel.X + vel.Y*vel.Y + vel.Z*vel.Z) footprintKm := FootprintRadiusKm(geo.Altitude) // Subsolar point sunECEF := SunPositionECEF(jd) subsolar := ECEFtoGeodetic(sunECEF) currTrackPt := TrackPoint{ Timestamp: t.Unix(), Latitude: math.Round(geo.Latitude*10000) / 10000, Longitude: math.Round(geo.Longitude*10000) / 10000, Altitude: math.Round(geo.Altitude*10) / 10, } if req.Observer != nil { l := CalculateLookAngles(*req.Observer, posECEF) az := math.Round(l.Azimuth*10) / 10 el := math.Round(l.Elevation*10) / 10 currTrackPt.Azimuth = &az currTrackPt.Elevation = &el } // Past track (-45 min to 0, every 30 seconds) var pastTrack []TrackPoint for s := -45 * 60; s < 0; s += 30 { ptTime := t.Add(time.Duration(s) * time.Second) pGeo, _, err := model.GeodeticPosition(ptTime) if err == nil { pt := TrackPoint{ Timestamp: ptTime.Unix(), Latitude: math.Round(pGeo.Latitude*1000) / 1000, Longitude: math.Round(pGeo.Longitude*1000) / 1000, Altitude: math.Round(pGeo.Altitude*10) / 10, } if req.Observer != nil { pECEF, _, errECEF := model.PositionECEF(ptTime) if errECEF == nil { l := CalculateLookAngles(*req.Observer, pECEF) az := math.Round(l.Azimuth*10) / 10 el := math.Round(l.Elevation*10) / 10 pt.Azimuth = &az pt.Elevation = &el } } pastTrack = append(pastTrack, pt) } } pastTrack = append(pastTrack, currTrackPt) // Future track (0 to +95 min, every 30 seconds) var futureTrack []TrackPoint futureTrack = append(futureTrack, currTrackPt) for s := 30; s <= 95*60; s += 30 { ptTime := t.Add(time.Duration(s) * time.Second) pGeo, _, err := model.GeodeticPosition(ptTime) if err == nil { pt := TrackPoint{ Timestamp: ptTime.Unix(), Latitude: math.Round(pGeo.Latitude*1000) / 1000, Longitude: math.Round(pGeo.Longitude*1000) / 1000, Altitude: math.Round(pGeo.Altitude*10) / 10, } if req.Observer != nil { pECEF, _, errECEF := model.PositionECEF(ptTime) if errECEF == nil { l := CalculateLookAngles(*req.Observer, pECEF) az := math.Round(l.Azimuth*10) / 10 el := math.Round(l.Elevation*10) / 10 pt.Azimuth = &az pt.Elevation = &el } } futureTrack = append(futureTrack, pt) } } // Instantaneous 3D orbital ring at timestamp t (fixed inertial frame relative to observer) var orbitalRing []TrackPoint periodMin := 92.8 if model.TLE.MeanMotionRevDay > 0 { periodMin = 1440.0 / model.TLE.MeanMotionRevDay } periodSec := periodMin * 60.0 steps := 120 stepSec := periodSec / float64(steps) for i := 0; i <= steps; i++ { tau := t.Add(time.Duration(float64(i)*stepSec) * time.Second) posTEME, _, errTEME := model.PropagateTime(tau) if errTEME == nil { // Freeze Earth at time t (jd) so the ring is a true static circle in ECEF/topocentric frame pRingECEF := TEMEtoECEF(posTEME, jd) geoRing := ECEFtoGeodetic(pRingECEF) pt := TrackPoint{ Timestamp: tau.Unix(), Latitude: math.Round(geoRing.Latitude*1000) / 1000, Longitude: math.Round(geoRing.Longitude*1000) / 1000, Altitude: math.Round(geoRing.Altitude*10) / 10, } if req.Observer != nil { l := CalculateLookAngles(*req.Observer, pRingECEF) az := math.Round(l.Azimuth*10) / 10 el := math.Round(l.Elevation*10) / 10 pt.Azimuth = &az pt.Elevation = &el } orbitalRing = append(orbitalRing, pt) } } var look *LookAngles if req.Observer != nil { l := CalculateLookAngles(*req.Observer, posECEF) look = &LookAngles{ Azimuth: math.Round(l.Azimuth*10) / 10, Elevation: math.Round(l.Elevation*10) / 10, Range: math.Round(l.Range*10) / 10, } } c.JSON(http.StatusOK, TrackResponse{ SatelliteID: entry.ID, SatelliteName: entry.Name, NoradID: entry.NoradID, CurrentTime: t, Latitude: math.Round(geo.Latitude*10000) / 10000, Longitude: math.Round(geo.Longitude*10000) / 10000, AltitudeKm: math.Round(geo.Altitude*10) / 10, VelocityKmS: math.Round(velKmS*100) / 100, FootprintKm: math.Round(footprintKm), IsSunlit: isSunlit, SubSolarPoint: Geodetic{ Latitude: math.Round(subsolar.Latitude*100) / 100, Longitude: math.Round(subsolar.Longitude*100) / 100, Altitude: 0, }, PastTrack: pastTrack, FutureTrack: futureTrack, OrbitalRing: orbitalRing, ObserverLook: look, }) }) // Pass prediction r.POST("/passes", func(c *gin.Context) { var req PassesRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } model, entry, err := getSGP4Model(req.SatelliteID, req.CustomTLE) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Days <= 0 || req.Days > 14 { req.Days = 10 } if req.MinElevationDeg <= 0 { req.MinElevationDeg = 10.0 } allPasses, err := PredictPasses(model, PredictPassesOptions{ Observer: req.Observer, StartTime: time.Now().UTC(), Days: req.Days, MinElevationDeg: req.MinElevationDeg, VisibleOnly: false, StandardMag: entry.StandardMag, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } filtered := allPasses if req.VisibleOnly { filtered = make([]SatellitePass, 0) for _, p := range allPasses { if p.Visibility == "visible" { filtered = append(filtered, p) } } } c.JSON(http.StatusOK, gin.H{ "satellite_id": entry.ID, "satellite_name": entry.Name, "norad_id": entry.NoradID, "observer": req.Observer, "days": req.Days, "visible_only": req.VisibleOnly, "total_count": len(allPasses), "count": len(filtered), "passes": filtered, }) }) // Instantaneous look angles & position (for AR mode & real-time sync) r.POST("/position", func(c *gin.Context) { var req PositionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } model, entry, err := getSGP4Model(req.SatelliteID, req.CustomTLE) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } t := time.Now().UTC() if req.Timestamp != nil && *req.Timestamp != "" { if parsed, err := time.Parse(time.RFC3339Nano, *req.Timestamp); err == nil { t = parsed.UTC() } else if parsed, err := time.Parse(time.RFC3339, *req.Timestamp); err == nil { t = parsed.UTC() } } geo, vel, err := model.GeodeticPosition(t) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } posECEF, _, _ := model.PositionECEF(t) look := CalculateLookAngles(req.Observer, posECEF) jd := JulianDate(t) isSunlit := IsSatelliteIlluminated(posECEF, jd) sunEl := ObserverSunElevation(req.Observer, t) obsECEF := GeodeticToECEF(req.Observer) apparentMag := EstimateVisualMagnitude(entry.StandardMag, satECEF(posECEF), obsECEF, jd) c.JSON(http.StatusOK, gin.H{ "timestamp": t.UnixNano() / 1e6, // ms "satellite_id": entry.ID, "satellite_name": entry.Name, "latitude": math.Round(geo.Latitude*10000) / 10000, "longitude": math.Round(geo.Longitude*10000) / 10000, "altitude_km": math.Round(geo.Altitude*10) / 10, "velocity_km_s": math.Round(math.Sqrt(vel.X*vel.X+vel.Y*vel.Y+vel.Z*vel.Z)*100) / 100, "azimuth": math.Round(look.Azimuth*10) / 10, "azimuth_cardinal": CardinalDirection(look.Azimuth), "elevation": math.Round(look.Elevation*10) / 10, "range_km": math.Round(look.Range*10) / 10, "is_sunlit": isSunlit, "sun_elevation": math.Round(sunEl*10) / 10, "apparent_mag": apparentMag, }) }) } func satECEF(v Vector3) Vector3 { return v }