diff --git a/frontend/src/components/EarthTwin.tsx b/frontend/src/components/EarthTwin.tsx index 68ab3ec..e3791c3 100644 --- a/frontend/src/components/EarthTwin.tsx +++ b/frontend/src/components/EarthTwin.tsx @@ -3,6 +3,15 @@ import { useUIStore } from '@/store/uiStore'; import { MaterialIcon } from './MaterialIcon'; import * as Cesium from 'cesium'; import { isSatelliteSunlit } from '../utils/illumination'; +import { useBookmarks } from '../hooks/useBookmarks'; +import type { Bookmark } from '../hooks/useBookmarkStorage'; +import { BookmarkModal, type BookmarkFormValues, } from './GlobeBookmarks/BookMarkModal'; +import { BookmarkSidebar } from './GlobeBookmarks/BookmarkSidebar'; +import { + createBookmarkShareUrl, + getSharedBookmarkFromUrl, +} from '../utils/bookmarkHelpers'; + interface CatalogObject { id: number; @@ -37,32 +46,32 @@ 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) { + obj.arg_of_perigee == null || obj.mean_anomaly == null || obj.mean_motion == null) { return null; } - const EARTH_RADIUS = 6371; + 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; @@ -79,29 +88,29 @@ function keplerToLatLonAlt(obj: CatalogObject, timeOffsetSec: number = 0): { lat const CATEGORY_COLORS = { - PAYLOAD: { css: '#00E5FF', cesium: Cesium.Color.fromCssColorString('#00E5FF'), label: 'Active Satellites', icon: 'satellite_alt' }, - DEBRIS: { css: '#FFAA00', cesium: Cesium.Color.fromCssColorString('#FFAA00'), label: 'Debris Objects', icon: 'delete_sweep' }, - ROCKET_BODY: { css: '#FF4444', cesium: Cesium.Color.fromCssColorString('#FF4444'), label: 'Rocket Bodies', icon: 'rocket' }, - UNKNOWN: { css: '#888888', cesium: Cesium.Color.fromCssColorString('#888888'), label: 'Unknown Objects', icon: 'help_outline' }, - COLLISION: { css: '#FF0000', cesium: Cesium.Color.fromCssColorString('#FF0000'), label: 'Collision Risk', icon: 'warning' }, - SELECTED: { css: '#00E5FF', cesium: Cesium.Color.fromCssColorString('#00E5FF'), label: 'Selected', icon: 'gps_fixed' }, + PAYLOAD: { css: '#00E5FF', cesium: Cesium.Color.fromCssColorString('#00E5FF'), label: 'Active Satellites', icon: 'satellite_alt' }, + DEBRIS: { css: '#FFAA00', cesium: Cesium.Color.fromCssColorString('#FFAA00'), label: 'Debris Objects', icon: 'delete_sweep' }, + ROCKET_BODY: { css: '#FF4444', cesium: Cesium.Color.fromCssColorString('#FF4444'), label: 'Rocket Bodies', icon: 'rocket' }, + UNKNOWN: { css: '#888888', cesium: Cesium.Color.fromCssColorString('#888888'), label: 'Unknown Objects', icon: 'help_outline' }, + COLLISION: { css: '#FF0000', cesium: Cesium.Color.fromCssColorString('#FF0000'), label: 'Collision Risk', icon: 'warning' }, + SELECTED: { css: '#00E5FF', cesium: Cesium.Color.fromCssColorString('#00E5FF'), label: 'Selected', icon: 'gps_fixed' }, } as const; function getPointSize(classification: string): number { switch (classification) { - case 'PAYLOAD': return 5; - case 'DEBRIS': return 4; + case 'PAYLOAD': return 5; + case 'DEBRIS': return 4; case 'ROCKET_BODY': return 6; - default: return 3; + default: return 3; } } function getOutlineWidth(classification: string): number { switch (classification) { - case 'PAYLOAD': return 1; - case 'DEBRIS': return 1; + case 'PAYLOAD': return 1; + case 'DEBRIS': return 1; case 'ROCKET_BODY': return 2; - default: return 1; + default: return 1; } } @@ -138,15 +147,17 @@ export const EarthTwin: React.FC = () => { const [showRiskOverlay, setShowRiskOverlay] = useState(false); const [objectCounts, setObjectCounts] = useState({ payloads: 0, debris: 0, rocketBodies: 0, total: 0, collisions: 0 }); const [dataLoaded, setDataLoaded] = useState(false); + const [isBookmarkModalOpen, setIsBookmarkModalOpen] = useState(false); + const [isBookmarkSidebarOpen, setIsBookmarkSidebarOpen] = useState(false); + const [editingBookmark, setEditingBookmark] = useState(null); - const [stats, setStats] = useState({ totalObjects: 0, lastSync: '', weatherIndex: 'K0', }); - + const populateEntities = useCallback(async (viewer: Cesium.Viewer) => { try { if (!viewer || viewer.isDestroyed()) return; @@ -156,7 +167,7 @@ export const EarthTwin: React.FC = () => { fetchCollisions(), ]); - + const collisionCatNums = new Set(); collisions.forEach(c => { if (c.object_a?.catalog_number) collisionCatNums.add(c.object_a.catalog_number); @@ -166,22 +177,22 @@ export const EarthTwin: React.FC = () => { let payloads = 0, debris = 0, rocketBodies = 0; const entityMap = new Map(); - + if (!viewer || viewer.isDestroyed()) return; - + viewer.entities.removeAll(); -const nowJulian = Cesium.JulianDate.now(); + const nowJulian = Cesium.JulianDate.now(); objects.forEach(obj => { const pos = keplerToLatLonAlt(obj); if (!pos) return; - + if (obj.classification === 'PAYLOAD') payloads++; else if (obj.classification === 'DEBRIS') debris++; else if (obj.classification === 'ROCKET_BODY') rocketBodies++; - + const isCollisionRisk = collisionCatNums.has(obj.catalog_number); const colorConfig = isCollisionRisk ? CATEGORY_COLORS.COLLISION @@ -191,7 +202,7 @@ const nowJulian = Cesium.JulianDate.now(); const outlineWidth = isCollisionRisk ? 3 : getOutlineWidth(obj.classification); const cartPos = Cesium.Cartesian3.fromDegrees(pos.lon, pos.lat, pos.alt * 1000); - const sunlit = isSatelliteSunlit(cartPos, nowJulian, viewer.scene.globe); + const sunlit = isSatelliteSunlit(cartPos, nowJulian, viewer.scene.globe); const entity = viewer.entities.add({ position: cartPos, @@ -217,7 +228,7 @@ const nowJulian = Cesium.JulianDate.now(); entityMap.set(obj.catalog_number, entity); }); - + collisions.forEach(conj => { if (!conj.object_a || !conj.object_b) return; const entityA = entityMap.get(conj.object_a.catalog_number); @@ -260,7 +271,173 @@ const nowJulian = Cesium.JulianDate.now(); } }, []); - + + const autoRotateRef = useRef(true); + + const { + bookmarks, + filteredBookmarks, + favoriteBookmarks, + recentBookmarks, + categories, + + searchQuery, + selectedCategory, + + setSearchQuery, + setSelectedCategory, + + addBookmark, + updateBookmark, + deleteBookmark, + toggleFavorite, + markAsRecent, + + exportBookmarks, + importBookmarks, + } = useBookmarks(); + + const [selectedBookmarkId, setSelectedBookmarkId] = + useState(''); + + + const saveCurrentView = useCallback(() => { + const viewer = viewerRef.current; + + if (!viewer || viewer.isDestroyed()) { + return; + } + + const cartographic = + viewer.scene.globe.ellipsoid.cartesianToCartographic( + viewer.camera.positionWC + ); + + if (!cartographic) { + return; + } + + const timestamp = new Intl.DateTimeFormat('en-IN', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date()); + + addBookmark({ + name: `Saved view — ${timestamp}`, + description: 'Saved from the current globe camera position.', + category: 'Custom', + + latitude: Cesium.Math.toDegrees(cartographic.latitude), + longitude: Cesium.Math.toDegrees(cartographic.longitude), + altitude: cartographic.height, + + // Cesium expects these values in radians when restoring. + heading: viewer.camera.heading, + pitch: viewer.camera.pitch, + roll: viewer.camera.roll, + }); + }, [addBookmark]); + + const restoreBookmark = useCallback( + (bookmark: Bookmark) => { + const viewer = viewerRef.current; + + if (!viewer || viewer.isDestroyed()) { + return; + } + + // Prevent the existing automatic globe rotation from immediately + // moving the camera away from the restored bookmark view. + autoRotateRef.current = false; + + markAsRecent(bookmark.id); + + const prefersReducedMotion = + window.matchMedia?.( + '(prefers-reduced-motion: reduce)' + ).matches ?? false; + + viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees( + bookmark.longitude, + bookmark.latitude, + bookmark.altitude + ), + orientation: { + heading: bookmark.heading, + pitch: bookmark.pitch, + roll: bookmark.roll, + }, + duration: prefersReducedMotion ? 0 : 1.5, + }); + }, + [markAsRecent] + ); + + const handleBookmarkSubmit = useCallback( + (values: BookmarkFormValues) => { + if (editingBookmark) { + updateBookmark(editingBookmark.id, { + name: values.name, + description: values.description, + category: values.category, + }); + + setEditingBookmark(null); + setIsBookmarkModalOpen(false); + return; + } + + const viewer = viewerRef.current; + + if (!viewer || viewer.isDestroyed()) { + return; + } + + const cartographic = + viewer.scene.globe.ellipsoid.cartesianToCartographic( + viewer.camera.positionWC + ); + + if (!cartographic) { + return; + } + + addBookmark({ + name: values.name, + description: values.description, + category: values.category, + + latitude: Cesium.Math.toDegrees(cartographic.latitude), + longitude: Cesium.Math.toDegrees(cartographic.longitude), + altitude: cartographic.height, + + heading: viewer.camera.heading, + pitch: viewer.camera.pitch, + roll: viewer.camera.roll, + }); + + setIsBookmarkModalOpen(false); + }, + [addBookmark, editingBookmark, updateBookmark] + ); + + const handleShareBookmark = useCallback( + async (bookmark: Bookmark) => { + const shareUrl = createBookmarkShareUrl(bookmark); + + try { + await navigator.clipboard.writeText(shareUrl); + } catch { + window.prompt( + 'Copy this bookmark link:', + shareUrl + ); + } + }, + [] + ); + useEffect(() => { if (!containerRef.current || !Cesium) return; if (viewerRef.current && !viewerRef.current.isDestroyed()) return; @@ -290,7 +467,7 @@ const nowJulian = Cesium.JulianDate.now(); // eslint-disable-next-line react-hooks/set-state-in-effect setUseFallback(false); - + viewer.scene.globe.enableLighting = true; // Enable real-time day/night lighting viewer.scene.backgroundColor = Cesium.Color.fromCssColorString('#050710'); viewer.scene.fog.enabled = false; @@ -298,7 +475,7 @@ const nowJulian = Cesium.JulianDate.now(); viewer.scene.skyAtmosphere.show = true; } - + viewer.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(30, 15, 20000000), orientation: { @@ -307,14 +484,42 @@ const nowJulian = Cesium.JulianDate.now(); roll: 0.0, }, }); + const sharedBookmark = getSharedBookmarkFromUrl(); + + if (sharedBookmark) { + autoRotateRef.current = false; + + const prefersReducedMotion = + window.matchMedia?.( + '(prefers-reduced-motion: reduce)' + ).matches ?? false; + + viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees( + sharedBookmark.longitude, + sharedBookmark.latitude, + sharedBookmark.altitude + ), + orientation: { + heading: sharedBookmark.heading, + pitch: sharedBookmark.pitch, + roll: sharedBookmark.roll, + }, + duration: prefersReducedMotion ? 0 : 1.5, + }); + } - onTick = () => { - viewer.scene.camera.rotate(Cesium.Cartesian3.UNIT_Z, 0.0003); + if (autoRotateRef.current) { + viewer.scene.camera.rotate( + Cesium.Cartesian3.UNIT_Z, + 0.0003 + ); + } }; viewer.clock.onTick.addEventListener(onTick); - + handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); handler.setInputAction((movement: { endPosition: Cesium.Cartesian2 }) => { @@ -333,7 +538,7 @@ const nowJulian = Cesium.JulianDate.now(); } }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); - + handler.setInputAction((click: { position: Cesium.Cartesian2 }) => { const picked = viewer.scene.pick(click.position); if (Cesium.defined(picked) && picked.id && picked.id.properties) { @@ -343,7 +548,7 @@ const nowJulian = Cesium.JulianDate.now(); const obj = JSON.parse(rawData) as CatalogObject; setSelectedSatelliteId(obj.catalog_number); - + const pos = keplerToLatLonAlt(obj); if (pos) { viewer.camera.flyTo({ @@ -356,10 +561,10 @@ const nowJulian = Cesium.JulianDate.now(); } }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - + populateEntities(viewer); - + const refreshInterval = setInterval(() => { if (viewerRef.current && !viewerRef.current.isDestroyed()) { populateEntities(viewerRef.current); @@ -382,7 +587,7 @@ const nowJulian = Cesium.JulianDate.now(); setUseFallback(true); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, []); return (
@@ -450,7 +655,7 @@ const nowJulian = Cesium.JulianDate.now(); )} {/* ── HUD Overlay ───────────────────────────────────────── */} -
+
{/* Top Row */}
@@ -469,30 +674,30 @@ const nowJulian = Cesium.JulianDate.now();
- {dataLoaded ? ( - <> - {objectCounts.collisions > 0 ? ( -
- - - {objectCounts.collisions} ACTIVE CONJUNCTION{objectCounts.collisions !== 1 ? 'S' : ''} - -
+ {dataLoaded ? ( + <> + {objectCounts.collisions > 0 ? ( +
+ + + {objectCounts.collisions} ACTIVE CONJUNCTION{objectCounts.collisions !== 1 ? 'S' : ''} + +
+ ) : ( +
+ + + ALL CLEAR + +
+ )} + ) : ( -
- - - ALL CLEAR - -
+ <> +
+
+ )} - - ) : ( - <> -
-
- - ) } @@ -507,52 +712,69 @@ const nowJulian = Cesium.JulianDate.now();
) : ( <> -
-

Objects Tracked

-

- {objectCounts.total.toLocaleString()} -

-
-
-

Debris

-

- {objectCounts.debris.toLocaleString()} -

-
-
-

Collision Risks

-

0 ? 'text-status-emergency animate-pulse drop-shadow-[0_0_8px_rgba(255,59,48,0.6)]' : 'text-status-success drop-shadow-[0_0_8px_rgba(0,255,136,0.4)]'}`}> - {objectCounts.collisions} -

-
- +
+

Objects Tracked

+

+ {objectCounts.total.toLocaleString()} +

+
+
+

Debris

+

+ {objectCounts.debris.toLocaleString()} +

+
+
+

Collision Risks

+

0 ? 'text-status-emergency animate-pulse drop-shadow-[0_0_8px_rgba(255,59,48,0.6)]' : 'text-status-success drop-shadow-[0_0_8px_rgba(0,255,136,0.4)]'}`}> + {objectCounts.collisions} +

+
+ ) - } + }
{/* Controls */}
+ + + + + + @@ -564,39 +786,92 @@ const nowJulian = Cesium.JulianDate.now(); {showLegend && (
{dataLoaded ? ( -
-
- ORBITAL LEGEND - -
-
- {[ - { color: CATEGORY_COLORS.PAYLOAD.css, label: 'Active Satellites', count: objectCounts.payloads }, - { color: CATEGORY_COLORS.DEBRIS.css, label: 'Debris Objects', count: objectCounts.debris }, - { color: CATEGORY_COLORS.ROCKET_BODY.css, label: 'Rocket Bodies', count: objectCounts.rocketBodies }, - { color: CATEGORY_COLORS.COLLISION.css, label: 'Collision Risks', count: objectCounts.collisions }, - ].map(item => ( -
-
- - {item.label} +
+
+ ORBITAL LEGEND + +
+
+ {[ + { color: CATEGORY_COLORS.PAYLOAD.css, label: 'Active Satellites', count: objectCounts.payloads }, + { color: CATEGORY_COLORS.DEBRIS.css, label: 'Debris Objects', count: objectCounts.debris }, + { color: CATEGORY_COLORS.ROCKET_BODY.css, label: 'Rocket Bodies', count: objectCounts.rocketBodies }, + { color: CATEGORY_COLORS.COLLISION.css, label: 'Collision Risks', count: objectCounts.collisions }, + ].map(item => ( +
+
+ + {item.label} +
+ {item.count.toLocaleString()}
- {item.count.toLocaleString()} -
- ))} -
-
- All positions from real orbital elements + ))} +
+
+ All positions from real orbital elements +
-
- ) : () } + ) : ()}
)} + + {/* Bookmark create/edit dialog */} + {isBookmarkModalOpen && ( + setIsBookmarkModalOpen(false)} + onSubmit={handleBookmarkSubmit} + /> + )} + + + {/* Bookmark sidebar */} + {isBookmarkSidebarOpen && ( + { + setEditingBookmark(null); + setIsBookmarkModalOpen(true); + }} + onShareBookmark={handleShareBookmark} + onEditBookmark={(bookmark) => { + setEditingBookmark(bookmark); + setIsBookmarkModalOpen(true); + }} + onDeleteBookmark={deleteBookmark} + onToggleFavorite={(bookmarkId) => + toggleFavorite(bookmarkId) + } + onExportBookmarks={exportBookmarks} + onImportBookmarks={importBookmarks} + onClose={() => setIsBookmarkSidebarOpen(false)} + /> + )} + + {isBookmarkModalOpen && ( + { + setEditingBookmark(null); + setIsBookmarkModalOpen(false); + }} + onSubmit={handleBookmarkSubmit} + /> + )} {/* ── Inline Styles for Animations ──────────────────────── */}