feat: add real-time NASA Horizons terminal data viewer
parent
32c3e6c71c
commit
21956f1c3b
|
|
@ -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}")
|
||||
async def update_celestial_body(
|
||||
body_id: str,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from astroquery.jplhorizons import Horizons
|
|||
from astropy.time import Time
|
||||
import logging
|
||||
import re
|
||||
import httpx
|
||||
|
||||
from app.models.celestial import Position, CelestialBody
|
||||
|
||||
|
|
@ -19,6 +20,42 @@ class HorizonsService:
|
|||
"""Initialize the service"""
|
||||
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(
|
||||
self,
|
||||
body_id: str,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
interface FocusInfoProps {
|
||||
|
|
@ -7,6 +10,10 @@ interface 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;
|
||||
|
||||
// Calculate distance if position is available
|
||||
|
|
@ -16,9 +23,58 @@ export function FocusInfo({ body, onClose }: FocusInfoProps) {
|
|||
const isProbe = body.type === 'probe';
|
||||
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 (
|
||||
// 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 */}
|
||||
<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>
|
||||
|
||||
{/* 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="p-1.5 rounded-full bg-blue-500/20 text-blue-400">
|
||||
<Ruler size={14} />
|
||||
|
|
@ -80,10 +136,71 @@ export function FocusInfo({ body, onClose }: FocusInfoProps) {
|
|||
</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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue