From bad376a5733fad331f01156a933188442c69c3fc Mon Sep 17 00:00:00 2001 From: Soham Pawar Date: Sat, 8 Aug 2026 14:30:05 +0530 Subject: [PATCH] feat: add real-time satellite flyby notifications --- frontend/src/components/EarthTwin.tsx | 41 +---- .../src/components/layouts/MainLayout.tsx | 13 +- .../src/components/ui/FlybyNotification.tsx | 143 ++++++++++++++++++ .../src/components/ui/NotificationCenter.tsx | 134 ++++++++++++++++ frontend/src/hooks/useFlybyEngine.ts | 130 ++++++++++++++++ frontend/src/store/notificationStore.ts | 66 ++++++++ frontend/src/store/uiStore.ts | 4 + frontend/src/utils/orbitCalc.ts | 91 +++++++++++ frontend/tsconfig.app.json | 1 - 9 files changed, 579 insertions(+), 44 deletions(-) create mode 100644 frontend/src/components/ui/FlybyNotification.tsx create mode 100644 frontend/src/components/ui/NotificationCenter.tsx create mode 100644 frontend/src/hooks/useFlybyEngine.ts create mode 100644 frontend/src/store/notificationStore.ts create mode 100644 frontend/src/utils/orbitCalc.ts diff --git a/frontend/src/components/EarthTwin.tsx b/frontend/src/components/EarthTwin.tsx index 2f282eb..048f33d 100644 --- a/frontend/src/components/EarthTwin.tsx +++ b/frontend/src/components/EarthTwin.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState, useCallback, useImperativeHandle, forwardRef } from 'react'; import { prefersReducedMotion } from './SatelliteSpotlight/GlowEffect'; +import { keplerToLatLonAlt } from '@/utils/orbitCalc'; import { useUIStore } from '@/store/uiStore'; import { logEvent } from '@/store/logbookStore'; import { MaterialIcon } from './MaterialIcon'; @@ -50,48 +51,8 @@ interface CollisionRisk { -function keplerToLatLonAlt(obj: CatalogObject, timeOffsetSec: number = 0): { lat: number; lon: number; alt: number } | null { - if (obj.semimajor_axis == null || obj.inclination == null || obj.raan == null || - obj.arg_of_perigee == null || obj.mean_anomaly == null || obj.mean_motion == null) { - return null; - } - - const EARTH_RADIUS = 6371; - const alt = obj.semimajor_axis - EARTH_RADIUS; - if (alt < 0 || alt > 100000) return null; - - - const epochDate = obj.epoch ? new Date(obj.epoch) : new Date(); - const now = new Date(); - const elapsedDays = (now.getTime() - epochDate.getTime()) / 86400000 + (timeOffsetSec / 86400); - const currentMeanAnomaly = ((obj.mean_anomaly + (elapsedDays * obj.mean_motion * 360)) % 360) * Math.PI / 180; - - - const ecc = obj.eccentricity ?? 0; - const trueAnomaly = currentMeanAnomaly + 2 * ecc * Math.sin(currentMeanAnomaly); - - - const argLat = (obj.arg_of_perigee * Math.PI / 180) + trueAnomaly; - const raanRad = obj.raan * Math.PI / 180; - const incRad = obj.inclination * Math.PI / 180; - - - const J2000 = new Date('2000-01-01T12:00:00Z').getTime(); - const daysSinceJ2000 = (now.getTime() + timeOffsetSec * 1000 - J2000) / 86400000; - const GMST = (280.46061837 + 360.98564736629 * daysSinceJ2000) % 360; - - const lon = ((Math.atan2( - Math.cos(incRad) * Math.sin(argLat), - Math.cos(argLat) - ) * 180 / Math.PI + (raanRad * 180 / Math.PI) - GMST + 540) % 360) - 180; - - const lat = Math.asin(Math.sin(incRad) * Math.sin(argLat)) * 180 / Math.PI; - - return { lat, lon, alt }; -} - const CATEGORY_COLORS = { PAYLOAD: { css: '#00E5FF', cesium: Cesium.Color.fromCssColorString('#00E5FF'), label: 'Active Satellites', icon: 'satellite_alt' }, diff --git a/frontend/src/components/layouts/MainLayout.tsx b/frontend/src/components/layouts/MainLayout.tsx index 4889c3d..f08295a 100644 --- a/frontend/src/components/layouts/MainLayout.tsx +++ b/frontend/src/components/layouts/MainLayout.tsx @@ -6,6 +6,7 @@ import { useUIStore } from '@/store/uiStore'; import { DynamicBackground } from '@/components/DynamicBackground/DynamicBackground'; import { LogbookPanel } from '@/components/Logbook/LogbookPanel'; import { useLogbookStore, logEvent } from '@/store/logbookStore'; +import { NotificationCenter } from '@/components/ui/NotificationCenter'; /** Ensures the mission-init System log fires once per browser tab session. */ let hasLoggedMissionInit = false; @@ -14,8 +15,10 @@ export const MainLayout: React.FC = () => { const { sidebarCollapsed, rightDrawerOpen, + isFlybyHistoryOpen, toggleSidebar, - toggleRightDrawer + toggleRightDrawer, + toggleFlybyHistory } = useUIStore(); const location = useLocation(); @@ -251,8 +254,11 @@ export const MainLayout: React.FC = () => { )} - + + +

+ {notification.satelliteName} +

+

+ Target: {notification.locationName} +

+ +
+
+

ETA

+

+ T-{minutesAway} MIN +

+
+
+

MAX ELEVATION

+

+ {notification.maxElevationDeg.toFixed(1)}° +

+
+
+

ALTITUDE

+

+ {notification.altitudeKm.toFixed(0)} KM +

+
+
+

VELOCITY

+

+ {notification.velocityKms.toFixed(2)} KM/S +

+
+
+ +
+ + +
+ + ); +}; diff --git a/frontend/src/components/ui/NotificationCenter.tsx b/frontend/src/components/ui/NotificationCenter.tsx new file mode 100644 index 0000000..cbda693 --- /dev/null +++ b/frontend/src/components/ui/NotificationCenter.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useNotificationStore } from '@/store/notificationStore'; +import { useUIStore } from '@/store/uiStore'; +import { useFlybyEngine } from '@/hooks/useFlybyEngine'; +import { FlybyNotification } from './FlybyNotification'; +import { MaterialIcon } from '../MaterialIcon'; + +export const NotificationCenter: React.FC = () => { + // Mount engine here so it runs globally + useFlybyEngine(); + + const notifications = useNotificationStore((s) => s.notifications); + const preferences = useNotificationStore((s) => s.preferences); + const updatePreferences = useNotificationStore((s) => s.updatePreferences); + const clearAll = useNotificationStore((s) => s.clearAll); + + const isHistoryOpen = useUIStore((s) => s.isFlybyHistoryOpen); + const toggleHistory = useUIStore((s) => s.toggleFlybyHistory); + + const activeNotifications = notifications.filter(n => !n.dismissed); + + return ( + <> + {/* Active Toasts - Bottom Right */} +
+ + {activeNotifications.slice(0, 3).map((notification) => ( +
+ +
+ ))} +
+
+ + {/* History & Settings Panel */} + + {isHistoryOpen && ( + +
+
+

FLYBY ALERTS

+

HISTORY & SETTINGS

+
+ +
+ +
+ {/* Settings Section */} +
+

PREFERENCES

+ +
+ Audio Alerts + +
+ +
+ Warning Window +
+ {[5, 10, 15].map(min => ( + + ))} +
+
+
+ + {/* History Section */} +
+
+

LOG

+ {notifications.length > 0 && ( + + )} +
+ + {notifications.length === 0 ? ( +

+ No flyby alerts recorded. +

+ ) : ( +
+ {notifications.map(n => ( +
+
+ {n.satelliteName} + + {n.createdAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + +
+

Over {n.locationName}

+

+ Max El: {n.maxElevationDeg.toFixed(1)}° | Alt: {n.altitudeKm.toFixed(0)}km +

+
+ ))} +
+ )} +
+
+
+ )} +
+ + ); +}; diff --git a/frontend/src/hooks/useFlybyEngine.ts b/frontend/src/hooks/useFlybyEngine.ts new file mode 100644 index 0000000..ec166fe --- /dev/null +++ b/frontend/src/hooks/useFlybyEngine.ts @@ -0,0 +1,130 @@ +import { useEffect, useRef } from 'react'; +import { useUIStore } from '@/store/uiStore'; +import { useNotificationStore } from '@/store/notificationStore'; +import { useBookmarkStorage } from '@/hooks/useBookmarkStorage'; +import { api } from '@/services/api'; +import type { SpaceObject } from '@/services/api'; +import { keplerToLatLonAlt, calculateGroundDistanceKm, calculateElevationAngle } from '@/utils/orbitCalc'; + +// Flyby check interval: every 30 seconds +const CHECK_INTERVAL_MS = 30 * 1000; +const MIN_ELEVATION_DEG = 10; // Minimum elevation to consider it a visible flyby +const PROPAGATION_MINUTES = 60; // Look ahead 60 minutes + +export function useFlybyEngine() { + const { selectedSatelliteIds } = useUIStore(); + const { preferences, addNotification } = useNotificationStore(); + const { bookmarks } = useBookmarkStorage(); + + const userLocationRef = useRef<{ lat: number, lon: number } | null>(null); + + // Initialize user location + useEffect(() => { + if ('geolocation' in navigator) { + navigator.geolocation.getCurrentPosition( + (position) => { + userLocationRef.current = { + lat: position.coords.latitude, + lon: position.coords.longitude, + }; + }, + (error) => { + console.warn('Geolocation denied or failed, using default location (0,0).', error); + userLocationRef.current = { lat: 0, lon: 0 }; + } + ); + } else { + userLocationRef.current = { lat: 0, lon: 0 }; + } + }, []); + + useEffect(() => { + if (selectedSatelliteIds.length === 0) return; + + const checkFlybys = async () => { + try { + // Fetch data for all tracked satellites + const satPromises = selectedSatelliteIds.map(id => api.getCatalogObjectByNorad(id)); + const responses = await Promise.allSettled(satPromises); + + const satellites = responses + .filter((res): res is PromiseFulfilledResult => res.status === 'fulfilled') + .map(res => res.value.data as SpaceObject) + .filter(Boolean); + + // Prepare target locations + const targetLocations = [ + ...(userLocationRef.current ? [{ + name: 'Current Location', + lat: userLocationRef.current.lat, + lon: userLocationRef.current.lon + }] : []), + ...bookmarks.map(b => ({ name: b.name, lat: b.latitude, lon: b.longitude })) + ]; + + const now = new Date(); + + for (const sat of satellites) { + // Cast SpaceObject to CatalogObject structure expected by keplerToLatLonAlt + const catalogObj = sat as any; + + for (const loc of targetLocations) { + let minDistance = Infinity; + let timeOfClosestApproachSec = 0; + let closestPos = null; + + // Propagate forward minute-by-minute + for (let min = 0; min <= PROPAGATION_MINUTES; min++) { + const timeOffsetSec = min * 60; + const pos = keplerToLatLonAlt(catalogObj, timeOffsetSec); + + if (pos) { + const dist = calculateGroundDistanceKm(loc.lat, loc.lon, pos.lat, pos.lon); + if (dist < minDistance) { + minDistance = dist; + timeOfClosestApproachSec = timeOffsetSec; + closestPos = pos; + } + } + } + + if (closestPos) { + const maxElevation = calculateElevationAngle(closestPos.alt, minDistance); + const etaDate = new Date(now.getTime() + timeOfClosestApproachSec * 1000); + const minutesUntilPass = timeOfClosestApproachSec / 60; + + // If it's a valid pass, and it's happening within our warning window + if ( + maxElevation >= MIN_ELEVATION_DEG && + minutesUntilPass > 0 && + minutesUntilPass <= preferences.warningMinutes + ) { + // Estimate velocity (simple circular orbit approximation for UI purposes) + const vKmS = Math.sqrt(398600.4418 / (6371 + closestPos.alt)); + + addNotification({ + satelliteId: sat.catalog_number, + satelliteName: sat.name || `NORAD ${sat.catalog_number}`, + locationName: loc.name, + eta: etaDate, + altitudeKm: closestPos.alt, + velocityKms: vKmS, + maxElevationDeg: maxElevation, + durationSec: 180, // Estimated visible duration 3 mins + }); + } + } + } + } + } catch (err) { + console.error('Flyby engine error:', err); + } + }; + + // Run immediately, then on interval + checkFlybys(); + const intervalId = setInterval(checkFlybys, CHECK_INTERVAL_MS); + + return () => clearInterval(intervalId); + }, [selectedSatelliteIds, bookmarks, preferences.warningMinutes, addNotification]); +} diff --git a/frontend/src/store/notificationStore.ts b/frontend/src/store/notificationStore.ts new file mode 100644 index 0000000..9518cea --- /dev/null +++ b/frontend/src/store/notificationStore.ts @@ -0,0 +1,66 @@ +import { create } from 'zustand'; + +export interface FlybyNotification { + id: string; + satelliteId: string; + satelliteName: string; + locationName: string; // E.g., 'Current Location' or 'London' + eta: Date; + altitudeKm: number; + velocityKms: number; + maxElevationDeg: number; + durationSec: number; + dismissed: boolean; + createdAt: Date; +} + +export interface NotificationPreferences { + soundEnabled: boolean; + warningMinutes: number; // 5, 10, or 15 +} + +interface NotificationState { + notifications: FlybyNotification[]; + preferences: NotificationPreferences; + addNotification: (notification: Omit) => void; + dismissNotification: (id: string) => void; + clearAll: () => void; + updatePreferences: (prefs: Partial) => void; +} + +export const useNotificationStore = create((set) => ({ + notifications: [], + preferences: { + soundEnabled: true, + warningMinutes: 10, + }, + addNotification: (notificationData) => set((state) => { + // Avoid duplicate active notifications for the same satellite and location within a short timeframe + const isDuplicate = state.notifications.some( + (n) => n.satelliteId === notificationData.satelliteId && + n.locationName === notificationData.locationName && + !n.dismissed && + Math.abs(n.eta.getTime() - notificationData.eta.getTime()) < 5 * 60 * 1000 // 5 min window + ); + + if (isDuplicate) return state; + + const newNotification: FlybyNotification = { + ...notificationData, + id: crypto.randomUUID(), + dismissed: false, + createdAt: new Date(), + }; + + return { notifications: [newNotification, ...state.notifications] }; + }), + dismissNotification: (id) => set((state) => ({ + notifications: state.notifications.map((n) => + n.id === id ? { ...n, dismissed: true } : n + ), + })), + clearAll: () => set({ notifications: [] }), + updatePreferences: (prefs) => set((state) => ({ + preferences: { ...state.preferences, ...prefs }, + })), +})); diff --git a/frontend/src/store/uiStore.ts b/frontend/src/store/uiStore.ts index 0e425e3..ccdeafd 100644 --- a/frontend/src/store/uiStore.ts +++ b/frontend/src/store/uiStore.ts @@ -9,6 +9,7 @@ interface UIState { selectedCollisionId: string | null; activeSector: string; globalSearchOpen: boolean; + isFlybyHistoryOpen: boolean; toggleSidebar: () => void; setSidebarCollapsed: (collapsed: boolean) => void; toggleRightDrawer: () => void; @@ -19,6 +20,7 @@ interface UIState { setSelectedCollisionId: (id: string | null) => void; setActiveSector: (sector: string) => void; setGlobalSearchOpen: (open: boolean) => void; + toggleFlybyHistory: () => void; } export const useUIStore = create((set) => ({ @@ -29,6 +31,7 @@ export const useUIStore = create((set) => ({ selectedCollisionId: null, activeSector: '', globalSearchOpen: false, + isFlybyHistoryOpen: false, toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })), setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }), toggleRightDrawer: () => set((state) => ({ rightDrawerOpen: !state.rightDrawerOpen })), @@ -58,4 +61,5 @@ export const useUIStore = create((set) => ({ setSelectedCollisionId: (id) => set({ selectedCollisionId: id }), setActiveSector: (sector) => set({ activeSector: sector }), setGlobalSearchOpen: (open) => set({ globalSearchOpen: open }), + toggleFlybyHistory: () => set((state) => ({ isFlybyHistoryOpen: !state.isFlybyHistoryOpen })), })); diff --git a/frontend/src/utils/orbitCalc.ts b/frontend/src/utils/orbitCalc.ts new file mode 100644 index 0000000..87e7cb7 --- /dev/null +++ b/frontend/src/utils/orbitCalc.ts @@ -0,0 +1,91 @@ +import type { CatalogObject } from '@/types/satellite'; + +const EARTH_RADIUS_KM = 6371; + +/** + * Calculates current or future position based on Keplerian elements. + * @param obj The satellite object containing orbital elements. + * @param timeOffsetSec Number of seconds into the future to propagate (0 for now). + * @returns The latitude, longitude (in degrees), and altitude (in km) or null if invalid. + */ +export function keplerToLatLonAlt(obj: CatalogObject, timeOffsetSec: number = 0): { lat: number; lon: number; alt: number } | null { + if (obj.semimajor_axis == null || obj.inclination == null || obj.raan == null || + obj.arg_of_perigee == null || obj.mean_anomaly == null || obj.mean_motion == null) { + return null; + } + + const alt = obj.semimajor_axis - EARTH_RADIUS_KM; + if (alt < 0 || alt > 100000) return null; + + const epochDate = obj.epoch ? new Date(obj.epoch) : new Date(); + const now = new Date(); + const elapsedDays = (now.getTime() - epochDate.getTime()) / 86400000 + (timeOffsetSec / 86400); + const currentMeanAnomaly = ((obj.mean_anomaly + (elapsedDays * obj.mean_motion * 360)) % 360) * Math.PI / 180; + + const ecc = obj.eccentricity ?? 0; + const trueAnomaly = currentMeanAnomaly + 2 * ecc * Math.sin(currentMeanAnomaly); + + const argLat = (obj.arg_of_perigee * Math.PI / 180) + trueAnomaly; + + const raanRad = obj.raan * Math.PI / 180; + const incRad = obj.inclination * Math.PI / 180; + + const J2000 = new Date('2000-01-01T12:00:00Z').getTime(); + const daysSinceJ2000 = (now.getTime() + timeOffsetSec * 1000 - J2000) / 86400000; + const GMST = (280.46061837 + 360.98564736629 * daysSinceJ2000) % 360; + + const lon = ((Math.atan2( + Math.cos(incRad) * Math.sin(argLat), + Math.cos(argLat) + ) * 180 / Math.PI + (raanRad * 180 / Math.PI) - GMST + 540) % 360) - 180; + + const lat = Math.asin(Math.sin(incRad) * Math.sin(argLat)) * 180 / Math.PI; + + return { lat, lon, alt }; +} + +/** + * Calculates the great-circle distance between two points on Earth using the Haversine formula. + */ +export function calculateGroundDistanceKm(lat1: number, lon1: number, lat2: number, lon2: number): number { + const toRad = (angle: number) => (angle * Math.PI) / 180; + + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + + const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return EARTH_RADIUS_KM * c; +} + +/** + * Computes the elevation angle of a satellite from a ground observer. + * @param satelliteAltKm The altitude of the satellite in km. + * @param groundDistanceKm The great circle ground distance from the observer to the satellite's nadir (sub-satellite point). + * @returns Elevation angle in degrees (0 is on horizon, 90 is directly overhead). + */ +export function calculateElevationAngle(satelliteAltKm: number, groundDistanceKm: number): number { + const rE = EARTH_RADIUS_KM; + const rS = EARTH_RADIUS_KM + satelliteAltKm; + + // Central angle between observer and satellite's nadir + const gammaRad = groundDistanceKm / rE; + + // Slant range (distance from observer to satellite) + const d = Math.sqrt(rE ** 2 + rS ** 2 - 2 * rE * rS * Math.cos(gammaRad)); + + // Elevation angle calculation + const cosEl = (rS * Math.sin(gammaRad)) / d; + + let elRad = Math.acos(cosEl); + + // If gamma > 90 deg, the satellite is definitely below the horizon, but Math.acos handles 0 to PI. + // Actually, wait, a standard way is to use atan2 or just simple geometry: + // el = atan( (cos(gamma) - (rE / rS)) / sin(gamma) ) + + const el = Math.atan2(Math.cos(gammaRad) - (rE / rS), Math.sin(gammaRad)); + return el * (180 / Math.PI); +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 3f21f57..2768475 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -7,7 +7,6 @@ "types": ["vite/client", "node"], "skipLibCheck": true, "baseUrl": ".", - "ignoreDeprecations": "6.0", "paths": { "@/*": ["./src/*"] },