diff --git a/frontend/src/components/EarthTwin.tsx b/frontend/src/components/EarthTwin.tsx index 11cb869..bba3a7f 100644 --- a/frontend/src/components/EarthTwin.tsx +++ b/frontend/src/components/EarthTwin.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react'; +import React, { useEffect, useRef, useState, useCallback, useImperativeHandle, forwardRef } from 'react'; +import { prefersReducedMotion } from './SatelliteSpotlight/GlowEffect'; import { useUIStore } from '@/store/uiStore'; import { MaterialIcon } from './MaterialIcon'; import { SpotlightManager } from './SatelliteSpotlight/SpotlightManager'; @@ -110,7 +111,12 @@ const LegendCardSkeleton = () => (
); -export const EarthTwin: React.FC = () => { +export interface EarthTwinHandle { + /** Flies the camera to the given NORAD catalog number and opens its info panel. */ + flyToSatellite: (catalogNumber: string) => void; +} + +export const EarthTwin = forwardRef((_props, ref) => { const containerRef = useRef(null); const viewerRef = useRef(null); const entitiesRef = useRef>(new Map()); @@ -119,6 +125,12 @@ export const EarthTwin: React.FC = () => { const tooltipRef = useRef(null); const { activeSector, setSelectedSatelliteId } = useUIStore(); const [useFallback, setUseFallback] = useState(true); + // Set by flyToSatellite(), consumed by SpotlightManager to lock its + // selection/info-card onto a satellite chosen from outside the globe + // (e.g. the Satellite of the Day card's "View on Globe" button). The + // nonce forces the effect to re-fire even if the same satellite is + // requested twice in a row. + const [focusRequest, setFocusRequest] = useState<{ catalogNumber: string; nonce: number } | null>(null); const [hoveredObject, setHoveredObject] = useState(null); const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 }); const [viewerInstance, setViewerInstance] = useState(null); @@ -333,6 +345,27 @@ export const EarthTwin: React.FC = () => { } }, []); + // Mirrors the double-click fly-to in useSatelliteSelection.ts, but + // triggerable from outside the globe (e.g. a dashboard card) instead of + // from a Cesium pick event. + const flyToSatellite = useCallback((catalogNumber: string) => { + const viewer = viewerRef.current; + const obj = catalogMapRef.current.get(catalogNumber); + if (!viewer || viewer.isDestroyed() || !obj) return; + + const pos = keplerToLatLonAlt(obj); + if (pos) { + const destination = Cesium.Cartesian3.fromDegrees(pos.lon, pos.lat, pos.alt * 1000 + 2000000); + viewer.camera.flyTo({ destination, duration: prefersReducedMotion() ? 0 : 1.5 }); + } + + // SpotlightManager owns actual selection state; this just tells it + // which satellite to lock onto and open the info panel for. + setFocusRequest({ catalogNumber, nonce: Date.now() }); + }, []); + + useImperativeHandle(ref, () => ({ flyToSatellite }), [flyToSatellite]); + return (
{/* 3D Cesium Container */} @@ -372,6 +405,7 @@ export const EarthTwin: React.FC = () => { collisionSetRef={collisionSetRef} datasetVersion={datasetVersion} toLatLonAlt={keplerToLatLonAlt} + focusRequest={focusRequest} onHoverChange={(obj, pos) => { setHoveredObject(obj); setTooltipPos(pos); @@ -576,5 +610,7 @@ export const EarthTwin: React.FC = () => { `}
); -}; -export default EarthTwin; +}); + +EarthTwin.displayName = 'EarthTwin'; +export default EarthTwin; \ No newline at end of file diff --git a/frontend/src/components/SatelliteOfTheDay/SpotlightCard.tsx b/frontend/src/components/SatelliteOfTheDay/SpotlightCard.tsx new file mode 100644 index 0000000..bacc820 --- /dev/null +++ b/frontend/src/components/SatelliteOfTheDay/SpotlightCard.tsx @@ -0,0 +1,179 @@ +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { MagicCard } from '@/components/ui/magic-card'; +import { MaterialIcon } from '@/components/MaterialIcon'; +import { api, type SpaceObject } from '@/services/api'; +import { useSatelliteOfTheDay } from '@/hooks/useSatelliteOfTheDay'; +import type { SatelliteStatus } from '@/constants/spotlightSatellites'; + +const EARTH_RADIUS_KM = 6371; + +interface OrbitSummary { + orbitType: 'LEO' | 'MEO' | 'GEO' | 'HEO'; + altitudeKm: number; + inclinationDeg: number; + periodMin: number; +} + +/** Classifies orbit regime from semimajor axis, mirroring common LEO/MEO/GEO/HEO bands. */ +function summarizeOrbit(obj: SpaceObject): OrbitSummary | null { + if (obj.semimajor_axis == null || obj.inclination == null || obj.period == null) return null; + const altitudeKm = obj.semimajor_axis - EARTH_RADIUS_KM; + + let orbitType: OrbitSummary['orbitType'] = 'LEO'; + if (altitudeKm > 2000 && altitudeKm < 35000) orbitType = 'MEO'; + else if (altitudeKm >= 35000 && altitudeKm <= 36500) orbitType = 'GEO'; + else if (altitudeKm > 36500) orbitType = 'HEO'; + + return { + orbitType, + altitudeKm: Math.round(altitudeKm), + inclinationDeg: Math.round(obj.inclination * 10) / 10, + periodMin: Math.round(obj.period), + }; +} + +const statusColor: Record = { + Active: 'text-status-success border-status-success', + Inactive: 'text-status-warning border-status-warning', + Retired: 'text-on-surface-variant border-border-panel', +}; + +const CardSkeleton = () => ( +
+
+
+
+
+ {[0, 1, 2, 3].map(i => ( +
+ ))} +
+
+); + +interface SpotlightCardProps { + /** Called with the real NORAD catalog number when "View on Globe" is clicked. */ + onViewOnGlobe: (catalogNumber: string) => void; +} + +export const SpotlightCard: React.FC = ({ onViewOnGlobe }) => { + const { satellite, isManualPick, showAnother, resetToToday } = useSatelliteOfTheDay(); + + const liveQuery = useQuery({ + queryKey: ['catalog', 'byNorad', satellite.catalog_number], + queryFn: () => api.getCatalogObjectByNorad(satellite.catalog_number), + retry: false, + }); + + const liveObject = liveQuery.data?.data; + const orbit = liveObject ? summarizeOrbit(liveObject) : null; + const canViewOnGlobe = liveQuery.isSuccess && orbit !== null; + + return ( + +
+
+ + + SATELLITE OF THE DAY + + +
+ + {liveQuery.isLoading ? ( + + ) : ( + <> +
+
+ {satellite.image ? ( + {satellite.name} { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} + /> + ) : ( + + )} +
+ +
+
+

{satellite.name}

+ + {satellite.status.toUpperCase()} + +
+

+ {satellite.operator} · launched {new Date(satellite.launch_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })} +

+

+ {satellite.mission_summary} +

+
+
+ +
+
+
ORBIT
+
{orbit?.orbitType ?? '\u2014'}
+
+
+
ALTITUDE
+
{orbit ? `${orbit.altitudeKm.toLocaleString()} km` : '\u2014'}
+
+
+
INCLINATION
+
{orbit ? `${orbit.inclinationDeg}\u00b0` : '\u2014'}
+
+
+
PERIOD
+
{orbit ? `${orbit.periodMin} min` : '\u2014'}
+
+
+ + {!canViewOnGlobe && !liveQuery.isLoading && ( +

+ Live orbital data isn't currently available for this object, it may have re-entered or dropped out of the tracked catalog. +

+ )} + + {satellite.facts.length > 0 && ( +
    + {satellite.facts.map((fact, i) => ( +
  • + + {fact} +
  • + ))} +
+ )} + + + + )} +
+
+ ); +}; + +export default SpotlightCard; \ No newline at end of file diff --git a/frontend/src/components/SatelliteSpotlight/SpotlightManager.tsx b/frontend/src/components/SatelliteSpotlight/SpotlightManager.tsx index 9111aff..6cec176 100644 --- a/frontend/src/components/SatelliteSpotlight/SpotlightManager.tsx +++ b/frontend/src/components/SatelliteSpotlight/SpotlightManager.tsx @@ -16,6 +16,8 @@ interface SpotlightManagerProps { toLatLonAlt: (obj: CatalogObject) => { lat: number; lon: number; alt: number } | null; onHoverChange?: (obj: CatalogObject | null, pos: { x: number; y: number }) => void; onSelectionChange?: (id: string | null) => void; + /** Set to lock selection onto a satellite from outside the globe (e.g. a dashboard card). */ + focusRequest?: { catalogNumber: string; nonce: number } | null; } /** @@ -36,6 +38,7 @@ export const SpotlightManager: React.FC = ({ toLatLonAlt, onHoverChange, onSelectionChange, + focusRequest, }) => { const handlerRef = useRef(null); const [handler, setHandler] = useState(null); @@ -71,6 +74,16 @@ export const SpotlightManager: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [hoveredObject, tooltipPos]); + // External fly-to request (e.g. Satellite of the Day's "View on Globe") + // — lock selection the same way a click would, keyed off `nonce` so the + // same satellite can be re-requested. + useEffect(() => { + if (!focusRequest) return; + const obj = catalogMapRef.current?.get(focusRequest.catalogNumber) ?? null; + if (obj) selectSatellite(obj); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusRequest]); + // Keyboard focus takes priority over mouse hover for spotlight targeting, // since it reflects deliberate keyboard navigation. const effectiveHoverId = keyboardFocusId ?? hoveredId; @@ -149,4 +162,4 @@ export const SpotlightManager: React.FC = ({ ); }; -export default SpotlightManager; +export default SpotlightManager; \ No newline at end of file diff --git a/frontend/src/constants/spotlightSatellites.ts b/frontend/src/constants/spotlightSatellites.ts new file mode 100644 index 0000000..c3ea660 --- /dev/null +++ b/frontend/src/constants/spotlightSatellites.ts @@ -0,0 +1,110 @@ +/** + * Curated "Satellite of the Day" dataset. + * + * This is hand-written editorial content (mission summary, operator, launch + * date, facts) because the backend catalog only stores orbital mechanics + * from TLE data — there's no mission/bio data anywhere in this app. + * + * `catalog_number` is the real NORAD Catalog Number and is used to cross- + * reference this entry against the live catalog (via + * api.getCatalogObjectByNorad) so orbit type / altitude / inclination / + * period always come from real, current data instead of also being + * hardcoded here. + * + * IMPORTANT: verify each catalog_number against the live /catalog/objects + * endpoint before merging — an object can decay/re-enter and drop out of + * the tracked catalog. The rotation hook and card component both handle a + * missing match gracefully, but it's worth confirming these are current. + */ + +export type SatelliteStatus = 'Active' | 'Inactive' | 'Retired'; + +export interface SpotlightSatellite { + /** Real NORAD Catalog Number, used to match against live catalog data. */ + catalog_number: string; + name: string; + operator: string; + launch_date: string; // ISO date, e.g. '1998-11-20' + status: SatelliteStatus; + mission_summary: string; + facts: string[]; + /** Optional; card falls back to a placeholder illustration if omitted. */ + image?: string; +} + +export const SPOTLIGHT_SATELLITES: SpotlightSatellite[] = [ + { + catalog_number: '25544', + name: 'International Space Station', + operator: 'NASA / Roscosmos / ESA / JAXA / CSA', + launch_date: '1998-11-20', + status: 'Active', + mission_summary: + 'A continuously crewed research platform in low Earth orbit, built and operated jointly by five space agencies for long-duration microgravity science.', + facts: [ + 'Orbits Earth roughly every 90 minutes — about 16 sunrises a day for the crew.', + 'The largest human-made structure ever assembled in space.', + ], + }, + { + catalog_number: '20580', + name: 'Hubble Space Telescope', + operator: 'NASA / ESA', + launch_date: '1990-04-24', + status: 'Active', + mission_summary: + 'A space-based observatory that images the universe in visible, ultraviolet, and near-infrared light, free of atmospheric distortion.', + facts: [ + 'Deployed from Space Shuttle Discovery on mission STS-31.', + "Its data has been used in over 20,000 peer-reviewed scientific papers.", + ], + }, + { + catalog_number: '25994', + name: 'Terra (EOS AM-1)', + operator: 'NASA', + launch_date: '1999-12-18', + status: 'Active', + mission_summary: + "Flagship of NASA's Earth Observing System, carrying five instruments that monitor clouds, land cover, and the carbon cycle.", + facts: [ + 'Flies in a sun-synchronous orbit, crossing the equator every morning at roughly the same local time.', + ], + }, + { + catalog_number: '27424', + name: 'Aqua', + operator: 'NASA', + launch_date: '2002-05-04', + status: 'Active', + mission_summary: + "Part of NASA's A-Train satellite constellation, gathering data on Earth's water cycle : evaporation, precipitation, and ice.", + facts: [ + 'Carries the AIRS instrument, used to improve weather forecasting models worldwide.', + ], + }, + { + catalog_number: '33591', + name: 'NOAA-19', + operator: 'NOAA', + launch_date: '2009-02-06', + status: 'Active', + mission_summary: + 'The final satellite in the POES series, providing global weather imagery and atmospheric soundings for forecasting.', + facts: [ + 'Data from NOAA polar orbiters feeds directly into daily weather forecasts and hurricane tracking.', + ], + }, + { + catalog_number: '39084', + name: 'Landsat 8', + operator: 'NASA / USGS', + launch_date: '2013-02-11', + status: 'Active', + mission_summary: + "Part of the longest continuous record of Earth's land surface as seen from space, running since 1972.", + facts: [ + 'Its imagery is freely available and widely used for tracking deforestation, urban growth, and agriculture.', + ], + }, +]; \ No newline at end of file diff --git a/frontend/src/hooks/useSatelliteOfTheDay.ts b/frontend/src/hooks/useSatelliteOfTheDay.ts new file mode 100644 index 0000000..7e9320a --- /dev/null +++ b/frontend/src/hooks/useSatelliteOfTheDay.ts @@ -0,0 +1,61 @@ +import { useCallback, useMemo, useState } from 'react'; +import { SPOTLIGHT_SATELLITES, type SpotlightSatellite } from '@/constants/spotlightSatellites'; + +/** + * Simple deterministic string hash (djb2). Used instead of Math.random() + * so the "satellite of the day" pick is the same for every visitor and + * only changes when the date string changes — no server state needed. + */ +function hashString(str: string): number { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = (hash * 33) ^ str.charCodeAt(i); + } + return Math.abs(hash); +} + +function todayKey(): string { + // Local calendar date, e.g. "2026-07-26" — rotates at local midnight. + return new Date().toLocaleDateString('en-CA'); +} + +interface UseSatelliteOfTheDayResult { + satellite: SpotlightSatellite; + /** True if the current pick came from "Show Another" rather than today's rotation. */ + isManualPick: boolean; + showAnother: () => void; + resetToToday: () => void; +} + +export function useSatelliteOfTheDay(): UseSatelliteOfTheDayResult { + const dailyIndex = useMemo(() => { + if (SPOTLIGHT_SATELLITES.length === 0) return 0; + return hashString(todayKey()) % SPOTLIGHT_SATELLITES.length; + }, []); + + const [manualIndex, setManualIndex] = useState(null); + + const showAnother = useCallback(() => { + setManualIndex(prev => { + if (SPOTLIGHT_SATELLITES.length <= 1) return prev; + let next = Math.floor(Math.random() * SPOTLIGHT_SATELLITES.length); + // Avoid landing back on whatever's currently shown. + const current = prev ?? dailyIndex; + while (next === current) { + next = Math.floor(Math.random() * SPOTLIGHT_SATELLITES.length); + } + return next; + }); + }, [dailyIndex]); + + const resetToToday = useCallback(() => setManualIndex(null), []); + + const activeIndex = manualIndex ?? dailyIndex; + + return { + satellite: SPOTLIGHT_SATELLITES[activeIndex], + isManualPick: manualIndex !== null, + showAnother, + resetToToday, + }; +} \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 59051fa..2f1e477 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,8 +1,9 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { motion } from 'framer-motion'; -import { EarthTwin } from '@/components/EarthTwin'; +import { EarthTwin, type EarthTwinHandle } from '@/components/EarthTwin'; import { MaterialIcon } from '@/components/MaterialIcon'; import { MagicCard } from '@/components/ui/magic-card'; +import { SpotlightCard } from '@/components/SatelliteOfTheDay/SpotlightCard'; import { useSpaceSummary } from '@/hooks/useApi'; import { useCollisions } from '@/hooks/useApi'; import { useAgentRuns } from '@/hooks/useApi'; @@ -48,6 +49,7 @@ const CollisionCardSkeleton = () => ( ); export const Dashboard: React.FC = () => { + const earthTwinRef = useRef(null); const summary = useSpaceSummary(); const collisions = useCollisions({ size: 5 }); const agents = useAgentRuns({ size: 6 }); @@ -118,7 +120,12 @@ export const Dashboard: React.FC = () => {
{/* 3D Earth Twin Digital Hero */}
- + +
+ + {/* Satellite of the Day */} +
+ earthTwinRef.current?.flyToSatellite(catalogNumber)} />
{/* KPI Bento Grid with Magic Cards */} @@ -330,4 +337,4 @@ export const Dashboard: React.FC = () => { ); }; -export default Dashboard; +export default Dashboard; \ No newline at end of file