feat: add real-time NASA Horizons terminal data viewer

main
mula.liu 2025-11-30 23:35:38 +08:00
parent 32c3e6c71c
commit 21956f1c3b
3 changed files with 181 additions and 3 deletions

View File

@ -111,6 +111,30 @@ async def search_celestial_body(
) )
@router.get("/{body_id}/nasa-data")
async def get_celestial_nasa_data(
body_id: str,
db: AsyncSession = Depends(get_db)
):
"""
Get raw text data from NASA Horizons for a celestial body
(Hacker terminal style output)
"""
# Check if body exists
body = await celestial_body_service.get_body_by_id(body_id, db)
if not body:
raise HTTPException(status_code=404, detail="Celestial body not found")
try:
# Fetch raw text from Horizons using the body_id
# Note: body.id corresponds to JPL Horizons ID
raw_text = await horizons_service.get_object_data_raw(body.id)
return {"id": body.id, "name": body.name, "raw_data": raw_text}
except Exception as e:
logger.error(f"Failed to fetch raw data for {body_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to fetch NASA data: {str(e)}")
@router.put("/{body_id}") @router.put("/{body_id}")
async def update_celestial_body( async def update_celestial_body(
body_id: str, body_id: str,

View File

@ -6,6 +6,7 @@ from astroquery.jplhorizons import Horizons
from astropy.time import Time from astropy.time import Time
import logging import logging
import re import re
import httpx
from app.models.celestial import Position, CelestialBody from app.models.celestial import Position, CelestialBody
@ -19,6 +20,42 @@ class HorizonsService:
"""Initialize the service""" """Initialize the service"""
self.location = "@sun" # Heliocentric coordinates self.location = "@sun" # Heliocentric coordinates
async def get_object_data_raw(self, body_id: str) -> str:
"""
Get raw object data (terminal style text) from Horizons
Args:
body_id: JPL Horizons ID
Returns:
Raw text response from NASA
"""
url = "https://ssd.jpl.nasa.gov/api/horizons.api"
# Ensure ID is quoted for COMMAND
cmd_val = f"'{body_id}'" if not body_id.startswith("'") else body_id
params = {
"format": "text",
"COMMAND": cmd_val,
"OBJ_DATA": "YES",
"MAKE_EPHEM": "NO",
"EPHEM_TYPE": "VECTORS",
"CENTER": "@sun"
}
try:
async with httpx.AsyncClient() as client:
logger.info(f"Fetching raw data for body {body_id}")
response = await client.get(url, params=params, timeout=30.0)
if response.status_code != 200:
raise Exception(f"NASA API returned status {response.status_code}")
return response.text
except Exception as e:
logger.error(f"Error fetching raw data for {body_id}: {str(e)}")
raise
def get_body_positions( def get_body_positions(
self, self,
body_id: str, body_id: str,

View File

@ -1,4 +1,7 @@
import { X, Ruler, Activity } from 'lucide-react'; import { X, Ruler, Activity, Radar } from 'lucide-react';
import { useState } from 'react';
import { Modal, message, Spin } from 'antd';
import { request } from '../utils/request';
import type { CelestialBody } from '../types'; import type { CelestialBody } from '../types';
interface FocusInfoProps { interface FocusInfoProps {
@ -7,6 +10,10 @@ interface FocusInfoProps {
} }
export function FocusInfo({ body, onClose }: FocusInfoProps) { export function FocusInfo({ body, onClose }: FocusInfoProps) {
const [showTerminal, setShowTerminal] = useState(false);
const [terminalData, setTerminalData] = useState('');
const [loading, setLoading] = useState(false);
if (!body) return null; if (!body) return null;
// Calculate distance if position is available // Calculate distance if position is available
@ -16,9 +23,58 @@ export function FocusInfo({ body, onClose }: FocusInfoProps) {
const isProbe = body.type === 'probe'; const isProbe = body.type === 'probe';
const isActive = body.is_active !== false; const isActive = body.is_active !== false;
const fetchNasaData = async () => {
setShowTerminal(true);
setLoading(true);
try {
const { data } = await request.get(`/celestial/${body.id}/nasa-data`);
setTerminalData(data.raw_data);
} catch (err) {
console.error(err);
message.error('连接 NASA Horizons 失败');
// If failed, maybe show error in terminal
setTerminalData("CONNECTION FAILED.\n\nError establishing link with JPL Horizons System.\nCheck connection frequencies.");
} finally {
setLoading(false);
}
};
const terminalStyles = `
.terminal-modal .ant-modal-content {
background-color: #0d1117 !important;
border: 1px solid #238636 !important;
box-shadow: 0 0 30px rgba(35, 134, 54, 0.15) !important;
color: #2ea043 !important;
padding: 0 !important;
overflow: hidden !important;
}
.terminal-modal .ant-modal-header {
background-color: #161b22 !important;
border-bottom: 1px solid #238636 !important;
margin-bottom: 0 !important;
}
.terminal-modal .ant-modal-title {
color: #2ea043 !important;
}
.terminal-modal .ant-modal-close {
color: #2ea043 !important;
}
.terminal-modal .ant-modal-close:hover {
background-color: rgba(35, 134, 54, 0.2) !important;
}
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-spin-slow {
animation: spin-slow 3s linear infinite;
}
`;
return ( return (
// Remove fixed positioning, now handled by parent container (Html component in 3D) // Remove fixed positioning, now handled by parent container (Html component in 3D)
<div className="flex flex-col items-center pointer-events-none -translate-y-24"> <div className="flex flex-col items-center -translate-y-24 pointer-events-none">
<style>{terminalStyles}</style>
{/* Main Info Card */} {/* Main Info Card */}
<div className="bg-black/80 backdrop-blur-xl border border-white/10 rounded-2xl p-5 min-w-[280px] max-w-sm shadow-2xl pointer-events-auto relative group mb-2"> <div className="bg-black/80 backdrop-blur-xl border border-white/10 rounded-2xl p-5 min-w-[280px] max-w-sm shadow-2xl pointer-events-auto relative group mb-2">
@ -55,7 +111,7 @@ export function FocusInfo({ body, onClose }: FocusInfoProps) {
</div> </div>
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2 mb-2">
<div className="bg-white/5 rounded-lg p-2 flex items-center gap-2.5 border border-white/5"> <div className="bg-white/5 rounded-lg p-2 flex items-center gap-2.5 border border-white/5">
<div className="p-1.5 rounded-full bg-blue-500/20 text-blue-400"> <div className="p-1.5 rounded-full bg-blue-500/20 text-blue-400">
<Ruler size={14} /> <Ruler size={14} />
@ -80,10 +136,71 @@ export function FocusInfo({ body, onClose }: FocusInfoProps) {
</div> </div>
)} )}
</div> </div>
{/* Actions Row */}
<div className="flex justify-end">
<button
onClick={fetchNasaData}
className="px-3 py-1.5 rounded-lg bg-cyan-950/30 text-cyan-400 border border-cyan-500/20 hover:bg-cyan-500/10 hover:border-cyan-500/50 transition-all flex items-center gap-2 text-[10px] font-mono uppercase tracking-widest group/btn"
title="连接 JPL Horizons System"
>
<Radar size={12} className="group-hover/btn:animate-spin-slow" />
<span>JPL Horizons</span>
</button>
</div>
</div> </div>
{/* Connecting Line/Triangle pointing down to the body */} {/* Connecting Line/Triangle pointing down to the body */}
<div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-t-[8px] border-t-black/80 backdrop-blur-xl mt-[-1px]"></div> <div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-t-[8px] border-t-black/80 backdrop-blur-xl mt-[-1px]"></div>
{/* Terminal Modal */}
<Modal
open={showTerminal}
onCancel={() => setShowTerminal(false)}
footer={null}
width={800}
centered
className="terminal-modal"
styles={{
header: {
backgroundColor: '#161b22',
borderBottom: '1px solid #238636',
padding: '12px 20px',
marginBottom: 0,
display: 'flex',
alignItems: 'center'
},
mask: {
backgroundColor: 'rgba(0, 0, 0, 0.85)',
backdropFilter: 'blur(4px)'
},
body: {
padding: '20px',
backgroundColor: '#0d1117'
}
}}
title={
<div className="flex items-center gap-2 text-[#2ea043] font-mono tracking-wider text-xs">
<div className="w-2 h-2 rounded-full bg-[#2ea043] animate-pulse"></div>
JPL/HORIZONS SYSTEM INTERFACE // {body.name.toUpperCase()}
</div>
}
closeIcon={<X size={18} style={{ color: '#2ea043' }} />}
>
<div className="h-[60vh] overflow-auto font-mono text-xs whitespace-pre-wrap scrollbar-none">
{loading ? (
<div className="flex items-center justify-center h-full flex-col gap-4 text-[#2ea043]">
<Spin indicator={<Radar className="animate-spin text-[#2ea043]" size={48} />} />
<div className="animate-pulse tracking-widest">ESTABLISHING SECURE UPLINK...</div>
<div className="text-[10px] opacity-50">Connecting to ssd.jpl.nasa.gov...</div>
</div>
) : (
<div className="animate-in fade-in duration-500">
{terminalData}
</div>
)}
</div>
</Modal>
</div> </div>
); );
} }