diff --git a/frontend/src/components/EarthTwin.tsx b/frontend/src/components/EarthTwin.tsx index 11cb869..f1c48f6 100644 --- a/frontend/src/components/EarthTwin.tsx +++ b/frontend/src/components/EarthTwin.tsx @@ -1,10 +1,7 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; import { useUIStore } from '@/store/uiStore'; import { MaterialIcon } from './MaterialIcon'; -import { SpotlightManager } from './SatelliteSpotlight/SpotlightManager'; -import type { CatalogObject, CollisionRisk } from '@/types/satellite'; -import { CATEGORY_COLORS, getPointSize, getOutlineWidth } from '@/lib/satelliteVisuals'; -import '@/styles/spotlight.css'; +import { useNavigate } from 'react-router-dom'; import * as Cesium from 'cesium'; import { isSatelliteSunlit } from '../utils/illumination'; import { useBookmarks } from '../hooks/useBookmarks'; @@ -16,6 +13,8 @@ import { getSharedBookmarkFromUrl, } from '../utils/bookmarkHelpers'; +import { useSpeechRecognition } from '../hooks/useSpeechRecognition'; +import { executeVoiceCommand } from '../lib/voiceCommands'; interface CatalogObject { id: number; @@ -50,33 +49,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 meanMotionRadPerSec = (obj.mean_motion * 2 * Math.PI) / 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; @@ -92,6 +90,34 @@ 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' }, +} as const; + +function getPointSize(classification: string): number { + switch (classification) { + case 'PAYLOAD': return 5; + case 'DEBRIS': return 4; + case 'ROCKET_BODY': return 6; + default: return 3; + } +} + +function getOutlineWidth(classification: string): number { + switch (classification) { + case 'PAYLOAD': return 1; + case 'DEBRIS': return 1; + case 'ROCKET_BODY': return 2; + default: return 1; + } +} + + const API_BASE = import.meta.env.VITE_API_URL ?? '/api/v1'; async function fetchAllCatalogObjects(): Promise { @@ -111,33 +137,89 @@ const LegendCardSkeleton = () => ( ); export const EarthTwin: React.FC = () => { + const navigate = useNavigate(); const containerRef = useRef(null); const viewerRef = useRef(null); const entitiesRef = useRef>(new Map()); - const catalogMapRef = useRef>(new Map()); - const collisionSetRef = useRef>(new Set()); const tooltipRef = useRef(null); const { activeSector, setSelectedSatelliteId } = useUIStore(); const [useFallback, setUseFallback] = useState(true); const [hoveredObject, setHoveredObject] = useState(null); const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 }); - const [viewerInstance, setViewerInstance] = useState(null); - const [containerEl, setContainerEl] = useState(null); - const [datasetVersion, setDatasetVersion] = useState(0); const [showLegend, setShowLegend] = useState(true); const [showDensity, setShowDensity] = useState(false); 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 handleTrackISS = useCallback(() => { + const viewer = viewerRef.current; + + if (!viewer || viewer.isDestroyed()) { + return; + } + + autoRotateRef.current = false; + + viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees( + 0, + 0, + 2_200_000 + ), + orientation: { + heading: 0, + pitch: Cesium.Math.toRadians(-85), + roll: 0, + }, + duration: 1.5, + }); + }, []); + + const handleShowDebris = useCallback(() => { + navigate('/Debris'); + }, [navigate]); + + const handleOpenCollisionCenter = useCallback(() => { + navigate('/dashboard/collision-center'); + }, [navigate]); + + const handleZoomToIndia = useCallback(() => { + const viewer = viewerRef.current; - + if (!viewer || viewer.isDestroyed()) { + return; + } + + autoRotateRef.current = false; + + viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees( + 78.9629, + 20.5937, + 3_000_000 + ), + orientation: { + heading: 0, + pitch: Cesium.Math.toRadians(-85), + roll: 0, + }, + duration: 1.5, + }); + }, []); + + const handleToggleSpaceWeather = useCallback(() => { + navigate('/dashboard/space-weather') + }, [navigate]); const [stats, setStats] = useState({ totalObjects: 0, lastSync: '', weatherIndex: 'K0', }); - + const populateEntities = useCallback(async (viewer: Cesium.Viewer) => { try { if (!viewer || viewer.isDestroyed()) return; @@ -147,7 +229,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); @@ -157,22 +239,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(); + viewer.entities.removeAll(); + 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 @@ -181,12 +263,15 @@ export const EarthTwin: React.FC = () => { const pixelSize = isCollisionRisk ? 8 : getPointSize(obj.classification); 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 entity = viewer.entities.add({ - position: Cesium.Cartesian3.fromDegrees(pos.lon, pos.lat, pos.alt * 1000), + position: cartPos, point: { pixelSize, - color: colorConfig.cesium.withAlpha(0.85), - outlineColor: colorConfig.cesium.withAlpha(0.4), + color: sunlit ? colorConfig.cesium.withAlpha(0.85) : colorConfig.cesium.withAlpha(0.3), + outlineColor: sunlit ? colorConfig.cesium.withAlpha(0.4) : colorConfig.cesium.withAlpha(0.1), outlineWidth, disableDepthTestDistance: Number.POSITIVE_INFINITY, scaleByDistance: new Cesium.NearFarScalar(1e6, 1.5, 5e7, 0.4), @@ -205,7 +290,7 @@ export const EarthTwin: React.FC = () => { 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); @@ -229,9 +314,6 @@ export const EarthTwin: React.FC = () => { }); entitiesRef.current = entityMap; - catalogMapRef.current = new Map(objects.map(obj => [obj.catalog_number, obj])); - collisionSetRef.current = collisionCatNums; - setDatasetVersion(v => v + 1); setObjectCounts({ payloads, debris, @@ -251,12 +333,211 @@ export const EarthTwin: React.FC = () => { } }, []); - + + 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 + ); + } + }, + [] + ); + + + const handleVoiceCommand = useCallback( + (transcript: string) => { + executeVoiceCommand(transcript, { + trackISS: handleTrackISS, + showDebris: handleShowDebris, + openCollisionCenter: handleOpenCollisionCenter, + zoomToIndia: handleZoomToIndia, + toggleSpaceWeather: handleToggleSpaceWeather, + }); + }, + [ + handleTrackISS, + handleShowDebris, + handleOpenCollisionCenter, + handleZoomToIndia, + handleToggleSpaceWeather, + ] + ); + + const { + isSupported, + isListening, + transcript, + error, + startListening, + stopListening, + } = useSpeechRecognition({ + onCommand: handleVoiceCommand, + }); + useEffect(() => { if (!containerRef.current || !Cesium) return; if (viewerRef.current && !viewerRef.current.isDestroyed()) return; let onTick: (() => void) | null = null; + let handler: Cesium.ScreenSpaceEventHandler | null = null; try { const viewer = new Cesium.Viewer(containerRef.current, { @@ -277,19 +558,18 @@ export const EarthTwin: React.FC = () => { }); viewerRef.current = viewer; + // eslint-disable-next-line react-hooks/set-state-in-effect setUseFallback(false); - setViewerInstance(viewer); - setContainerEl(containerRef.current); - - viewer.scene.globe.enableLighting = false; + + viewer.scene.globe.enableLighting = true; // Enable real-time day/night lighting viewer.scene.backgroundColor = Cesium.Color.fromCssColorString('#050710'); viewer.scene.fog.enabled = false; if (viewer.scene.skyAtmosphere) { viewer.scene.skyAtmosphere.show = true; } - + viewer.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(30, 15, 20000000), orientation: { @@ -298,17 +578,87 @@ export const EarthTwin: React.FC = () => { 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 }) => { + const picked = viewer.scene.pick(movement.endPosition); + if (Cesium.defined(picked) && picked.id && picked.id.properties) { + try { + const rawData = picked.id.properties.catalogData?.getValue(Cesium.JulianDate.now()); + if (rawData) { + const obj = JSON.parse(rawData) as CatalogObject; + setHoveredObject(obj); + setTooltipPos({ x: movement.endPosition.x + 15, y: movement.endPosition.y - 10 }); + } + } catch { /* ignore parse errors */ } + } else { + setHoveredObject(null); + } + }, 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) { + try { + const rawData = picked.id.properties.catalogData?.getValue(Cesium.JulianDate.now()); + if (rawData) { + const obj = JSON.parse(rawData) as CatalogObject; + setSelectedSatelliteId(obj.catalog_number); + + + const pos = keplerToLatLonAlt(obj); + if (pos) { + viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees(pos.lon, pos.lat, pos.alt * 1000 + 2000000), + duration: 1.5, + }); + } + } + } catch { /* ignore parse errors */ } + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + + populateEntities(viewer); - + const refreshInterval = setInterval(() => { if (viewerRef.current && !viewerRef.current.isDestroyed()) { populateEntities(viewerRef.current); @@ -317,32 +667,26 @@ export const EarthTwin: React.FC = () => { return () => { clearInterval(refreshInterval); + if (handler) handler.destroy(); const v = viewerRef.current; if (v && !v.isDestroyed()) { if (onTick) v.clock.onTick.removeEventListener(onTick); v.destroy(); } viewerRef.current = null; - setViewerInstance(null); - setContainerEl(null); }; } catch (e) { console.warn('Cesium initialization failed, using fallback', e); setUseFallback(true); } - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return (
{/* 3D Cesium Container */} -
+
{/* High-Fidelity SVG Fallback */} {useFallback && ( @@ -363,22 +707,6 @@ export const EarthTwin: React.FC = () => {
)} - {/* ── Interactive Satellite Spotlight ─────────────────────── */} - { - setHoveredObject(obj); - setTooltipPos(pos); - }} - onSelectionChange={(id) => setSelectedSatelliteId(id)} - /> - {/* ── Hover Tooltip (Glassmorphism) ──────────────────────── */} {hoveredObject && (
{ )} {/* ── HUD Overlay ───────────────────────────────────────── */} -
+
{/* Top Row */}
@@ -440,30 +768,30 @@ export const EarthTwin: React.FC = () => {
- {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 - -
+ <> +
+
+ )} - - ) : ( - <> -
-
- - ) } @@ -478,52 +806,107 @@ export const EarthTwin: React.FC = () => {
) : ( <> -
-

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 */}
+ + {(isListening || transcript || error) && ( +
+
+ {isListening && ( +

+ LISTENING FOR COMMAND... +

+ )} + + {transcript && ( +

+ "{transcript}" +

+ )} + + {error && ( +

+ Voice error: {error} +

+ )} +
+
+ )} + + + + + @@ -535,39 +918,92 @@ export const EarthTwin: React.FC = () => { {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 ──────────────────────── */}