From 4a22d48b194e7e83427a4e10deb3ef4cdb57515b Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Sun, 30 Nov 2025 00:58:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E5=8A=9F=E8=83=BD=E5=9F=BA?= =?UTF-8?q?=E6=9C=AC=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package.json | 1 + frontend/src/App.tsx | 63 ++-- frontend/src/components/AsteroidBelts.tsx | 139 +++++++++ frontend/src/components/CameraController.tsx | 80 ++++- frontend/src/components/CelestialBody.tsx | 4 +- frontend/src/components/Constellations.tsx | 15 +- frontend/src/components/ControlPanel.tsx | 120 ++++++++ frontend/src/components/FocusInfo.tsx | 89 ++++++ frontend/src/components/Galaxies.tsx | 12 +- frontend/src/components/Header.tsx | 185 ++--------- .../src/components/InterstellarTicker.tsx | 106 +++++++ frontend/src/components/Nebulae.tsx | 9 +- frontend/src/components/OrbitRenderer.tsx | 8 +- frontend/src/components/ProbeList.tsx | 289 +++++++++--------- frontend/src/components/Scene.tsx | 40 ++- frontend/src/components/Stars.tsx | 12 +- .../src/components/TimelineController.tsx | 134 ++++---- frontend/src/hooks/useScreenshot.ts | 77 +++++ frontend/src/hooks/useSpaceData.ts | 6 +- frontend/src/utils/renderPosition.ts | 68 +++-- frontend/yarn.lock | 5 + 21 files changed, 1017 insertions(+), 445 deletions(-) create mode 100644 frontend/src/components/AsteroidBelts.tsx create mode 100644 frontend/src/components/ControlPanel.tsx create mode 100644 frontend/src/components/FocusInfo.tsx create mode 100644 frontend/src/components/InterstellarTicker.tsx create mode 100644 frontend/src/hooks/useScreenshot.ts diff --git a/frontend/package.json b/frontend/package.json index 6e0a5e7..12dba8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "@react-three/fiber": "^9.4.0", "antd": "^6.0.0", "axios": "^1.13.2", + "lucide-react": "^0.555.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.9.6", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9f47866..db02ddb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,22 +3,31 @@ * Main application component */ import { useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useSpaceData } from './hooks/useSpaceData'; import { useHistoricalData } from './hooks/useHistoricalData'; import { useTrajectory } from './hooks/useTrajectory'; +import { useScreenshot } from './hooks/useScreenshot'; import { Header } from './components/Header'; import { Scene } from './components/Scene'; import { ProbeList } from './components/ProbeList'; import { TimelineController } from './components/TimelineController'; import { Loading } from './components/Loading'; +import { InterstellarTicker } from './components/InterstellarTicker'; +import { ControlPanel } from './components/ControlPanel'; import type { CelestialBody } from './types'; // Timeline configuration - will be fetched from backend later const TIMELINE_DAYS = 30; // Total days in timeline range function App() { + const navigate = useNavigate(); const [selectedDate, setSelectedDate] = useState(null); const [isTimelineMode, setIsTimelineMode] = useState(false); + const [showOrbits, setShowOrbits] = useState(true); + const [isSoundOn, setIsSoundOn] = useState(false); + const [showDanmaku, setShowDanmaku] = useState(true); + const { takeScreenshot } = useScreenshot(); // Use real-time data or historical data based on mode const { bodies: realTimeBodies, loading: realTimeLoading, error: realTimeError } = useSpaceData(); @@ -79,14 +88,26 @@ function App() { return (
- {/* Header with navigation and controls */} + {/* Header with simplified branding */}
+ {/* Right Control Panel */} + setShowOrbits(!showOrbits)} + isSoundOn={isSoundOn} + onToggleSound={() => setIsSoundOn(!isSoundOn)} + showDanmaku={showDanmaku} + onToggleDanmaku={() => setShowDanmaku(!showDanmaku)} + onLogin={() => navigate('/login')} + onScreenshot={takeScreenshot} + /> + {/* Probe List Sidebar */} {/* Timeline Controller */} @@ -111,23 +134,23 @@ function App() { /> )} - {/* Instructions overlay */} -
- {selectedBody ? ( - <> -

聚焦模式

-

点击侧边栏的"返回太阳系视图"按钮

- - ) : ( - <> -

太阳系俯视图

-

🖱️ 左键拖动: 旋转

-

🖱️ 右键拖动: 平移

-

🖱️ 滚轮: 缩放

-

点击左侧天体列表查看详情

- - )} -
+ {/* Interstellar Ticker Sound (Controlled) */} + + + {/* Instructions overlay (Only show when exploring freely) */} + {!selectedBody && ( +
+
+ 左键 旋转 +
+
+ 右键 平移 +
+
+ 滚轮 缩放 +
+
+ )}
); } diff --git a/frontend/src/components/AsteroidBelts.tsx b/frontend/src/components/AsteroidBelts.tsx new file mode 100644 index 0000000..06ccd03 --- /dev/null +++ b/frontend/src/components/AsteroidBelts.tsx @@ -0,0 +1,139 @@ +import { useMemo, useRef, useState, useEffect } from 'react'; +import { useFrame } from '@react-three/fiber'; +import * as THREE from 'three'; +import { scaleDistance } from '../utils/scaleDistance'; +import { fetchStaticData } from '../utils/api'; + +interface BeltProps { + innerRadiusAU: number; + outerRadiusAU: number; + count: number; + color: string; + size: number; + opacity: number; + heightScale: number; // How much vertical spread relative to radius + rotationSpeed: number; +} + +interface BeltData { + category: string; + name: string; + name_zh: string; + data: BeltProps; +} + +function Belt({ + innerRadiusAU, + outerRadiusAU, + count, + color, + size, + opacity, + heightScale = 0.05, + rotationSpeed +}: BeltProps) { + const pointsRef = useRef(null); + + // Generate particles + const geometry = useMemo(() => { + const positions = new Float32Array(count * 3); + const colors = new Float32Array(count * 3); + const baseColor = new THREE.Color(color); + + for (let i = 0; i < count; i++) { + // Random distance between inner and outer radius + // Use square root distribution for uniform area density + const r = Math.sqrt(Math.random() * (outerRadiusAU**2 - innerRadiusAU**2) + innerRadiusAU**2); + + // Apply scaleDistance to match the scene's non-linear scaling + const scaledR = scaleDistance(r); + + const theta = Math.random() * Math.PI * 2; + + // Height variation (gaussian-ish distribution) + const h = (Math.random() - 0.5) * 2 * scaledR * heightScale * Math.random(); + + const x = scaledR * Math.cos(theta); + const y = h; // Y is Up in Scene + const z = scaledR * Math.sin(theta); + + // Assign positions + positions[i * 3] = x; + positions[i * 3 + 1] = y; + positions[i * 3 + 2] = z; + + // Color variation + const variation = (Math.random() - 0.5) * 0.2; + colors[i * 3] = Math.min(1, Math.max(0, baseColor.r + variation)); + colors[i * 3 + 1] = Math.min(1, Math.max(0, baseColor.g + variation)); + colors[i * 3 + 2] = Math.min(1, Math.max(0, baseColor.b + variation)); + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + return geo; + }, [innerRadiusAU, outerRadiusAU, count, color, heightScale]); + + // Animation: Rotate the entire belt + useFrame((_, delta) => { + if (pointsRef.current) { + pointsRef.current.rotation.y += rotationSpeed * delta; + } + }); + + return ( + + + + ); +} + +export function AsteroidBelts() { + const [asteroidBeltData, setAsteroidBeltData] = useState(null); + const [kuiperBeltData, setKuiperBeltData] = useState(null); + + useEffect(() => { + const loadBelts = async () => { + try { + // Fetch Asteroid Belt + const asteroidRes = await fetchStaticData('asteroid_belt'); + if (asteroidRes.items.length > 0) { + setAsteroidBeltData(asteroidRes.items[0].data); + } + + // Fetch Kuiper Belt + const kuiperRes = await fetchStaticData('kuiper_belt'); + if (kuiperRes.items.length > 0) { + setKuiperBeltData(kuiperRes.items[0].data); + } + } catch (err) { + console.error('Failed to load belt data:', err); + } + }; + + loadBelts(); + }, []); + + return ( + + {/* Main Asteroid Belt */} + {asteroidBeltData && ( + + )} + + {/* Kuiper Belt */} + {kuiperBeltData && ( + + )} + + ); +} diff --git a/frontend/src/components/CameraController.tsx b/frontend/src/components/CameraController.tsx index 765c3e9..8227531 100644 --- a/frontend/src/components/CameraController.tsx +++ b/frontend/src/components/CameraController.tsx @@ -19,14 +19,31 @@ export function CameraController({ focusTarget, allBodies, onAnimationComplete } const isAnimating = useRef(false); const animationProgress = useRef(0); const startPosition = useRef(new Vector3()); + + // Track previous focus to detect changes vs updates + const lastFocusId = useRef(null); + const lastTargetPos = useRef(null); useEffect(() => { if (focusTarget) { // Focus on target - use smart rendered position - const pos = focusTarget.positions[0]; const renderPos = calculateRenderPosition(focusTarget, allBodies); - const scaledPos = { x: renderPos.x, y: renderPos.y, z: renderPos.z }; + const currentTargetPos = new Vector3(renderPos.x, renderPos.z, renderPos.y); // Note: Y/Z swap in rendering + // Check if we are just updating positions for the same body + if (lastFocusId.current === focusTarget.id && lastTargetPos.current) { + // Same body, position changed (timeline playing) + // Move camera by the same delta to maintain relative view + const delta = new Vector3().subVectors(currentTargetPos, lastTargetPos.current); + camera.position.add(delta); + + // Update tracking ref + lastTargetPos.current.copy(currentTargetPos); + return; // Skip animation and reset logic + } + + // New target or first load: Calculate ideal camera position + const pos = focusTarget.positions[0]; const distance = Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2); const parentInfo = findParentPlanet(focusTarget, allBodies); @@ -70,21 +87,64 @@ export function CameraController({ focusTarget, allBodies, onAnimationComplete } } targetPosition.current.set( - scaledPos.x + (offset * sideMultiplier), - scaledPos.z + (offset * heightMultiplier), - scaledPos.y + offset + currentTargetPos.x + (offset * sideMultiplier), + currentTargetPos.y + (offset * heightMultiplier), // already swapped in currentTargetPos? No, currentTargetPos is X, Z, Y world coords. + // Wait, let's look at the original code logic carefully. + // Original: scaledPos.x + offset, scaledPos.z + offset, scaledPos.y + offset + // currentTargetPos is created as (renderPos.x, renderPos.z, renderPos.y). + // In Three.js scene: x=x, y=z(up), z=y(depth) usually? + // Let's verify Scene coordinate system. + // Scene.tsx: controlsTarget = [scaledPos.x, scaledPos.z, scaledPos.y] + // So Y is up in Threejs, but Z is Up in data? + // renderPos (from calculateRenderPosition) usually returns data coords. + // let's stick to the exact mapping used in the original code. + // Original: + // targetPosition.current.set( + // scaledPos.x + (offset * sideMultiplier), + // scaledPos.z + (offset * heightMultiplier), + // scaledPos.y + offset + // ); + // So: X_cam = X_data + off + // Y_cam = Z_data + off <-- Y is up in ThreeJS, so Z_data is mapped to Y + // Z_cam = Y_data + off + currentTargetPos.y + (offset * heightMultiplier), // Z_data is in .y of currentTargetPos because we did new Vector3(x, z, y) + currentTargetPos.z + offset // Y_data is in .z of currentTargetPos ); + // Wait, my `currentTargetPos` construction `new Vector3(renderPos.x, renderPos.z, renderPos.y)` + // maps Data X->X, Data Z->Y, Data Y->Z. + // So currentTargetPos.x = Data X + // currentTargetPos.y = Data Z (Up) + // currentTargetPos.z = Data Y (Depth) + // This matches the Scene.tsx controlsTarget `[scaledPos.x, scaledPos.z, scaledPos.y]`. + + // So the calculation should be relative to currentTargetPos: + targetPosition.current.set( + currentTargetPos.x + (offset * sideMultiplier), + currentTargetPos.y + (offset * heightMultiplier), + currentTargetPos.z + offset + ); + + // Update tracking refs + lastFocusId.current = focusTarget.id; + if (!lastTargetPos.current) { + lastTargetPos.current = new Vector3(); + } + lastTargetPos.current.copy(currentTargetPos); // Start animation startPosition.current.copy(camera.position); isAnimating.current = true; animationProgress.current = 0; } else { - // Return to solar system overview (angled view) - targetPosition.current.set(50, 40, 50); - startPosition.current.copy(camera.position); - isAnimating.current = true; - animationProgress.current = 0; + // No target - stop any ongoing animation and reset tracking refs. + // Do NOT force camera to return to solar system overview. + // OrbitControls will maintain the current camera position. + if (isAnimating.current) { // If an animation was in progress, stop it. + isAnimating.current = false; + animationProgress.current = 0; + } + lastFocusId.current = null; + lastTargetPos.current = null; } }, [focusTarget, allBodies, camera]); diff --git a/frontend/src/components/CelestialBody.tsx b/frontend/src/components/CelestialBody.tsx index 1684ab3..ea4b006 100644 --- a/frontend/src/components/CelestialBody.tsx +++ b/frontend/src/components/CelestialBody.tsx @@ -192,7 +192,7 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur {/* Saturn Rings */} - {body.name === 'Saturn' && } + {body.id === '699' && } {/* Sun glow effect */} {body.type === 'star' && ( @@ -252,7 +252,7 @@ export function CelestialBody({ body, allBodies }: CelestialBodyProps) { const appearance = useMemo(() => { if (body.type === 'star') { return { - size: 0.4, // Slightly larger sun for better visibility + size: 0.4, // Revert to original size emissive: '#FDB813', emissiveIntensity: 1.5, }; diff --git a/frontend/src/components/Constellations.tsx b/frontend/src/components/Constellations.tsx index 025b639..c8c395f 100644 --- a/frontend/src/components/Constellations.tsx +++ b/frontend/src/components/Constellations.tsx @@ -23,7 +23,7 @@ interface Constellation { * Convert RA/Dec to Cartesian coordinates * Use fixed distance for constellation stars to create celestial sphere effect */ -function raDecToCartesian(ra: number, dec: number, distance: number = 100) { +function raDecToCartesian(ra: number, dec: number, distance: number = 5000) { const raRad = (ra * Math.PI) / 180; const decRad = (dec * Math.PI) / 180; @@ -93,8 +93,8 @@ export function Constellations() { {/* Render constellation stars */} {constellation.starPositions.map((pos, idx) => ( - - + + ))} @@ -104,7 +104,7 @@ export function Constellations() { key={`${constellation.name}-line-${idx}`} points={[segment.start, segment.end]} color="#4488FF" - lineWidth={1} + lineWidth={1.5} transparent opacity={0.5} /> @@ -113,11 +113,12 @@ export function Constellations() { {/* Constellation name label */} {constellation.nameZh} diff --git a/frontend/src/components/ControlPanel.tsx b/frontend/src/components/ControlPanel.tsx new file mode 100644 index 0000000..7aa5c2d --- /dev/null +++ b/frontend/src/components/ControlPanel.tsx @@ -0,0 +1,120 @@ +import { + Calendar, + Orbit, + Volume2, + VolumeX, + MessageSquare, + LogIn, + Eye, + EyeOff, + Camera +} from 'lucide-react'; + +interface ControlPanelProps { + isTimelineMode: boolean; + onToggleTimeline: () => void; + showOrbits: boolean; + onToggleOrbits: () => void; + isSoundOn: boolean; + onToggleSound: () => void; + showDanmaku: boolean; + onToggleDanmaku: () => void; + onLogin: () => void; + onScreenshot: () => void; +} + +export function ControlPanel({ + isTimelineMode, + onToggleTimeline, + showOrbits, + onToggleOrbits, + isSoundOn, + onToggleSound, + showDanmaku, + onToggleDanmaku, + onLogin, + onScreenshot +}: ControlPanelProps) { + const buttonClass = (isActive: boolean) => ` + p-2 rounded-lg transition-all duration-200 relative group + ${isActive + ? 'bg-blue-600 text-white shadow-lg shadow-blue-500/50' + : 'bg-white/10 text-gray-300 hover:bg-white/20 border border-white/5' + } + `; + + const tooltipClass = "absolute right-full mr-2 top-1/2 -translate-y-1/2 px-2 py-1 bg-gray-900 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50"; + + return ( +
+ {/* Timeline Toggle */} + + + {/* Orbit Toggle */} + + + {/* Sound Toggle */} + + + {/* Danmaku Toggle (Mock) */} + + + {/* Screenshot Button */} + + + {/* Divider */} +
+ + {/* Login Button */} + +
+ ); +} diff --git a/frontend/src/components/FocusInfo.tsx b/frontend/src/components/FocusInfo.tsx new file mode 100644 index 0000000..5e33c8d --- /dev/null +++ b/frontend/src/components/FocusInfo.tsx @@ -0,0 +1,89 @@ +import { X, Ruler, Activity } from 'lucide-react'; +import type { CelestialBody } from '../types'; + +interface FocusInfoProps { + body: CelestialBody | null; + onClose: () => void; +} + +export function FocusInfo({ body, onClose }: FocusInfoProps) { + if (!body) return null; + + // Calculate distance if position is available + const pos = body.positions[0]; + const distance = pos ? Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2).toFixed(2) : '---'; + + const isProbe = body.type === 'probe'; + const isActive = body.is_active !== false; + + return ( + // Remove fixed positioning, now handled by parent container (Html component in 3D) +
+ {/* Main Info Card */} +
+ + {/* Close Button */} + + + {/* Header */} +
+
+
+

+ {body.name_zh || body.name} +

+ + {isProbe ? '探测器' : '天体'} + +
+

+ {body.description || '暂无描述'} +

+
+
+ + {/* Stats Grid */} +
+
+
+ +
+
+
日心距离
+
{distance} AU
+
+
+ + {isProbe && ( +
+
+ +
+
+
状态
+
+ {isActive ? '运行中' : '已失效'} +
+
+
+ )} +
+
+ + {/* Connecting Line/Triangle pointing down to the body */} +
+
+ ); +} diff --git a/frontend/src/components/Galaxies.tsx b/frontend/src/components/Galaxies.tsx index 54712ad..63d9d88 100644 --- a/frontend/src/components/Galaxies.tsx +++ b/frontend/src/components/Galaxies.tsx @@ -102,8 +102,8 @@ function raDecToCartesian(ra: number, dec: number, distance: number) { function calculateAngularSize(diameterKly: number, distanceMly: number): number { // Angular diameter in radians const angularDiameter = diameterKly / (distanceMly * 1000); - // Significantly reduced multiplier for much smaller galaxies - return Math.max(0.5, angularDiameter * 200); + // Significantly increased multiplier for larger distance + return Math.max(20, angularDiameter * 8000); } export function Galaxies() { @@ -133,12 +133,12 @@ export function Galaxies() { const galaxyData = useMemo(() => { return galaxies.map((galaxy) => { // Place galaxies on celestial sphere at fixed distance for visualization - const visualDistance = 200; // Fixed distance for celestial sphere + const visualDistance = 5000; // Fixed distance for celestial sphere (was 200) const position = raDecToCartesian(galaxy.ra, galaxy.dec, visualDistance); // Calculate visual size based on actual properties const size = galaxy.type === 'galactic_center' - ? 2 // Smaller for Milky Way center + ? 80 // Much larger for Milky Way center : calculateAngularSize(galaxy.diameter_kly, galaxy.distance_mly); // Create procedural texture for this galaxy @@ -184,11 +184,11 @@ export function Galaxies() { {/* Galaxy name label - positioned slightly outward from galaxy */} {galaxy.name_zh} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 6cea709..a756557 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,166 +1,37 @@ /** - * Header component - application header with navigation and controls + * Header component - simple branding */ -import { useState } from 'react'; - -interface HeaderProps { - isTimelineMode: boolean; - onToggleTimeline: () => void; - bodyCount: number; - selectedBodyName?: string; -} - -export function Header({ - isTimelineMode, - onToggleTimeline, - bodyCount, - selectedBodyName -}: HeaderProps) { - const [showLoginModal, setShowLoginModal] = useState(false); - +export function Header({ selectedBodyName, bodyCount }: { selectedBodyName?: string, bodyCount: number }) { return ( - <> -
-
- {/* Left: Logo and Title */} -
-
-
- 🌌 -
-
-

Cosmo

-

宇宙星空可视化平台

-
-
- - {/* Status Info */} -
-

- {selectedBodyName ? ( - <> - 聚焦: {selectedBodyName} - - ) : ( - <> - {bodyCount} 个天体 - - )} -

-
-
- - {/* Right: Controls */} +
+
+
- {/* Timeline Mode Toggle */} -
- {/* Tooltip */} - {!isTimelineMode && ( -
- 查看天体历史运动轨迹 -
+ {/* Status Info */} +
+

+ {selectedBodyName ? ( + <> + 聚焦: {selectedBodyName} + + ) : ( + <> + {bodyCount} 个天体在线 + )} - - - {/* Login Button */} - +

- -
- - {/* Login Modal */} - {showLoginModal && ( -
setShowLoginModal(false)} - > -
e.stopPropagation()} - > -
-

登录 Cosmo

- -
- -
-
- - -
- -
- - -
- -
- - - 忘记密码? - -
- - - -
- 还没有账号?{' '} - - 立即注册 - -
-
-
-
- )} - +
+
); -} +} \ No newline at end of file diff --git a/frontend/src/components/InterstellarTicker.tsx b/frontend/src/components/InterstellarTicker.tsx new file mode 100644 index 0000000..09d5da6 --- /dev/null +++ b/frontend/src/components/InterstellarTicker.tsx @@ -0,0 +1,106 @@ +import { useEffect, useRef, useCallback } from 'react'; + +interface InterstellarTickerProps { + isPlaying: boolean; +} + +export function InterstellarTicker({ isPlaying }: InterstellarTickerProps) { + const audioContextRef = useRef(null); + const timerRef = useRef(null); + const nextNoteTimeRef = useRef(0); + + // 初始化 AudioContext (必须在用户交互后触发) + const initAudio = useCallback(() => { + if (!audioContextRef.current) { + const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext; + audioContextRef.current = new AudioContextClass(); + } + if (audioContextRef.current.state === 'suspended') { + audioContextRef.current.resume(); + } + }, []); + + // 播放单次滴答声 + const playTick = useCallback((time: number) => { + const ctx = audioContextRef.current; + if (!ctx) return; + + // 1. 创建振荡器 (声音源) + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + + // 2. 声音设计 - 模仿星际穿越的木鱼/秒表声 + // 使用正弦波,频率较高 + osc.type = 'sine'; + osc.frequency.setValueAtTime(880, time); // A5 音高,清脆 + + // 3. 包络 (Envolope) - 极短的冲击感 + // 0s: 静音 + gain.gain.setValueAtTime(0, time); + // 0.005s: 快速达到峰值 (Attack) + gain.gain.linearRampToValueAtTime(0.8, time + 0.005); + // 0.05s: 快速衰减 (Decay) + gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05); + + // 4. 连接并播放 + osc.connect(gain); + gain.connect(ctx.destination); + + osc.start(time); + osc.stop(time + 0.1); + }, []); + + // 调度器 (Lookahead Scheduler) + const scheduler = useCallback(() => { + const ctx = audioContextRef.current; + if (!ctx) return; + + // 预读 0.1 秒 + const lookahead = 0.1; + const interval = 1.2; // 1.2 秒间隔 + + // 如果下一个音符的时间在当前时间 + 预读范围内,则调度它 + while (nextNoteTimeRef.current < ctx.currentTime + lookahead) { + playTick(nextNoteTimeRef.current); + nextNoteTimeRef.current += interval; + } + + timerRef.current = window.setTimeout(scheduler, 25); + }, [playTick]); + + // 监听 isPlaying 变化 + useEffect(() => { + if (isPlaying) { + // 开启声音 + initAudio(); + + // 重置调度时间 + if (audioContextRef.current) { + // 稍微延迟一点开始,避免切歌时的爆音 + nextNoteTimeRef.current = audioContextRef.current.currentTime + 0.1; + scheduler(); + } + } else { + // 关闭声音 + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + } + }, [isPlaying, initAudio, scheduler]); + + // 组件卸载时清理 + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + if (audioContextRef.current) { + audioContextRef.current.close(); + } + }; + }, []); + + // 这是一个纯逻辑组件,不渲染任何 UI + return null; +} \ No newline at end of file diff --git a/frontend/src/components/Nebulae.tsx b/frontend/src/components/Nebulae.tsx index 0896f19..bc3d196 100644 --- a/frontend/src/components/Nebulae.tsx +++ b/frontend/src/components/Nebulae.tsx @@ -155,7 +155,8 @@ function calculateAngularSize(diameterLy: number, distanceLy: number): number { // Angular diameter in radians const angularDiameter = diameterLy / distanceLy; // Scale for visualization (nebulae should be visible but not too large) - return Math.max(1.5, Math.min(8, angularDiameter * 3000)); + // Increased multiplier for larger distance + return Math.max(50, Math.min(300, angularDiameter * 100000)); } export function Nebulae() { @@ -185,7 +186,7 @@ export function Nebulae() { const nebulaData = useMemo(() => { return nebulae.map((nebula) => { // Place nebulae on celestial sphere at fixed distance for visualization - const visualDistance = 150; // Between constellations (100) and galaxies (200) + const visualDistance = 5000; // Moved from 150 to 5000 const position = raDecToCartesian(nebula.ra, nebula.dec, visualDistance); // Calculate visual size based on angular diameter @@ -234,11 +235,11 @@ export function Nebulae() { {/* Nebula name label - positioned slightly outward */} {nebula.name_zh} diff --git a/frontend/src/components/OrbitRenderer.tsx b/frontend/src/components/OrbitRenderer.tsx index 523b08f..cc9281d 100644 --- a/frontend/src/components/OrbitRenderer.tsx +++ b/frontend/src/components/OrbitRenderer.tsx @@ -17,7 +17,11 @@ interface OrbitData { periodDays: number | null; } -export function OrbitRenderer() { +interface OrbitRendererProps { + visible?: boolean; +} + +export function OrbitRenderer({ visible = true }: OrbitRendererProps) { const [orbits, setOrbits] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -109,7 +113,7 @@ export function OrbitRenderer() { } return ( - + {orbits.map((orbit) => ( { - const pos = probe.positions[0]; - const distance = Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2); - return { body: probe, distance }; - }); + // Calculate distance for sorting + const calculateDistance = (body: CelestialBody) => { + const pos = body.positions[0]; + return Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2); + }; - // Calculate distance for each planet (exclude Sun) - const planetsWithDistance = planets - .filter((p) => p.type !== 'star') // Exclude Sun - .map((planet) => { - const pos = planet.positions[0]; - const distance = Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2); - return { body: planet, distance }; - }); + const processBodies = (list: CelestialBody[]) => { + return list + .filter(b => + (b.name_zh || b.name).toLowerCase().includes(searchTerm.toLowerCase()) && + b.type !== 'star' // Exclude Sun from list + ) + .map(body => ({ + body, + distance: calculateDistance(body) + })) + .sort((a, b) => a.distance - b.distance); + }; - // Sort by distance - probesWithDistance.sort((a, b) => a.distance - b.distance); - planetsWithDistance.sort((a, b) => a.distance - b.distance); - - // Collapsed state - show only toggle button - if (!isExpanded) { - return ( - - ); - } + const planetList = processBodies(planets); + const probeList = processBodies(probes); return ( -
-
-

- 🌍 天体列表 -

- -
- - {/* Planets Section */} -
-

- 行星 ({planetsWithDistance.length}) -

- {planetsWithDistance.length === 0 ? ( -
加载中...
- ) : ( -
- {planetsWithDistance.map(({ body, distance }) => ( - - ))} +
+ {/* Main Content Panel */} +
+ {/* Header & Search */} +
+
+

天体导航

{/* text-lg -> text-base */} +
- )} -
- - {/* Probes Section */} -
-

- 探测器 ({probesWithDistance.length}) -

- {probesWithDistance.length === 0 ? ( -
加载中...
- ) : ( -
- {probesWithDistance.map(({ body, distance }) => { - const isInactive = body.is_active === false; - const isSelected = selectedBody?.id === body.id; - - return ( - - ); - })} + +
+ + setSearchTerm(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded-lg pl-9 pr-3 py-2 text-xs text-white placeholder-gray-500 focus:outline-none focus:border-blue-500/50 transition-colors" + />
- )} +
+ + {/* List Content */} +
+ {/* Planets Group */} +
+
{/* text-xs -> text-[10px] */} + + 行星 ({planetList.length}) +
+
+ {planetList.map(({ body, distance }) => ( + onBodySelect(body)} + /> + ))} +
+
+ + {/* Probes Group */} +
+
{/* text-xs -> text-[10px] */} + + 探测器 ({probeList.length}) +
+
+ {probeList.map(({ body, distance }) => ( + onBodySelect(body)} + /> + ))} +
+
+
- {/* Return button */} -
- -
+ {/* Toggle Button (Attached to the side) */} +
); } + +function BodyItem({ body, distance, isSelected, onClick }: { + body: CelestialBody, + distance: number, + isSelected: boolean, + onClick: () => void +}) { + const isInactive = body.is_active === false; + + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/components/Scene.tsx b/frontend/src/components/Scene.tsx index 3eb07a9..c0c14d1 100644 --- a/frontend/src/components/Scene.tsx +++ b/frontend/src/components/Scene.tsx @@ -2,7 +2,7 @@ * Main 3D Scene component */ import { Canvas } from '@react-three/fiber'; -import { OrbitControls, Stars as BackgroundStars } from '@react-three/drei'; +import { OrbitControls, Stars as BackgroundStars, Html } from '@react-three/drei'; import { useMemo } from 'react'; import { CelestialBody } from './CelestialBody'; import { Probe } from './Probe'; @@ -13,16 +13,21 @@ import { Stars } from './Stars'; import { Constellations } from './Constellations'; import { Galaxies } from './Galaxies'; import { Nebulae } from './Nebulae'; +import { FocusInfo } from './FocusInfo'; +import { AsteroidBelts } from './AsteroidBelts'; import { scalePosition } from '../utils/scaleDistance'; +import { calculateRenderPosition } from '../utils/renderPosition'; import type { CelestialBody as CelestialBodyType, Position } from '../types'; interface SceneProps { bodies: CelestialBodyType[]; selectedBody: CelestialBodyType | null; trajectoryPositions?: Position[]; + showOrbits?: boolean; + onBodySelect?: (body: CelestialBodyType | null) => void; } -export function Scene({ bodies, selectedBody, trajectoryPositions = [] }: SceneProps) { +export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbits = true, onBodySelect }: SceneProps) { // Separate natural celestial bodies (planets/dwarf planets/satellites/stars) from probes const celestialBodies = bodies.filter((b) => b.type !== 'probe'); const probes = bodies.filter((b) => b.type === 'probe'); @@ -40,17 +45,30 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [] }: SceneP return [0, 0, 0] as [number, number, number]; }, [selectedBody]); + // Calculate position for FocusInfo (needs to match rendered position of body) + const focusInfoPosition = useMemo(() => { + if (!selectedBody) return [0, 0, 0] as [number, number, number]; + + // We need to use the EXACT same logic as CelestialBody/Probe components + // to ensure the label sticks to the object + const renderPos = calculateRenderPosition(selectedBody, bodies); + + // Convert to Three.js coordinates (x, z, y) + return [renderPos.x, renderPos.z, renderPos.y] as [number, number, number]; + }, [selectedBody, bodies]); + return (
{ gl.sortObjects = true; // Enable object sorting by renderOrder @@ -88,13 +106,16 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [] }: SceneP {/* Distant galaxies */} + {/* Asteroid Belts & Kuiper Belt */} + + {/* Render all celestial bodies: planets, dwarf planets, satellites, and stars */} {celestialBodies.map((body) => ( ))} {/* Unified orbit renderer for all celestial bodies (planets and dwarf planets) */} - + {/* Render visible probes with 3D models */} {visibleProbes.map((body) => ( @@ -120,6 +141,13 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [] }: SceneP target={controlsTarget} enabled={true} // Always enabled /> + + {/* Dynamic Focus Info Label */} + {selectedBody && onBodySelect && ( + + onBodySelect(null)} /> + + )}
); diff --git a/frontend/src/components/Stars.tsx b/frontend/src/components/Stars.tsx index 0f93515..1ffe857 100644 --- a/frontend/src/components/Stars.tsx +++ b/frontend/src/components/Stars.tsx @@ -21,7 +21,7 @@ interface Star { * Dec: Declination (-90 to 90 degrees) * Distance: fixed distance for celestial sphere */ -function raDecToCartesian(ra: number, dec: number, distance: number = 150) { +function raDecToCartesian(ra: number, dec: number, distance: number = 5000) { // Convert to radians const raRad = (ra * Math.PI) / 180; const decRad = (dec * Math.PI) / 180; @@ -42,7 +42,7 @@ function magnitudeToSize(magnitude: number): number { // Brighter stars (lower magnitude) should be slightly larger // But all stars should be very small compared to planets const normalized = Math.max(-2, Math.min(12, magnitude)); - return Math.max(0.15, 0.6 - normalized * 0.04); + return Math.max(5, 20 - normalized * 1.2); } export function Stars() { @@ -75,9 +75,9 @@ export function Stars() { const starData = useMemo(() => { return stars.map((star) => { - // Place all stars on a celestial sphere at fixed distance (150 units) + // Place all stars on a celestial sphere at fixed distance (5000 units) // This way they appear as background objects, similar to constellations - const position = raDecToCartesian(star.ra, star.dec, 150); + const position = raDecToCartesian(star.ra, star.dec, 5000); // Size based on brightness (magnitude) const size = magnitudeToSize(star.magnitude); @@ -123,11 +123,11 @@ export function Stars() { {/* Star name label - positioned radially outward from star */} {star.name_zh} diff --git a/frontend/src/components/TimelineController.tsx b/frontend/src/components/TimelineController.tsx index 0a27450..f6363bf 100644 --- a/frontend/src/components/TimelineController.tsx +++ b/frontend/src/components/TimelineController.tsx @@ -2,6 +2,7 @@ * TimelineController - controls time for viewing historical positions */ import { useState, useEffect, useCallback, useRef } from 'react'; +import { Play, Pause, RotateCcw, FastForward, CalendarClock } from 'lucide-react'; export interface TimelineState { currentDate: Date; @@ -116,66 +117,83 @@ export function TimelineController({ onTimeChange, minDate, maxDate }: TimelineC }; return ( -
- {/* Current date display - full format */} -
- {formatFullDate(currentDate)} -
- - {/* Date range labels */} -
- {formatShortDate(startDate)} - {formatShortDate(endDate)} -
- - {/* Progress bar (standard slider) */} - - - {/* Controls */} -
- {/* Play/Pause button */} - - - {/* Speed control */} -
- 速度: - +
+
+ {/* Header: Date & Icon */} +
+
+ + 历史回溯 +
+
+ {formatFullDate(currentDate)} +
- {/* Reset button */} - + {/* Progress Bar */} +
+
+ {formatShortDate(startDate)} (现在) + {formatShortDate(endDate)} (过去) +
+ +
+ + {/* Controls */} +
+ {/* Speed Selector */} +
+ + +
+ + {/* Main Controls */} +
+ + + +
+
); diff --git a/frontend/src/hooks/useScreenshot.ts b/frontend/src/hooks/useScreenshot.ts new file mode 100644 index 0000000..7b22d2d --- /dev/null +++ b/frontend/src/hooks/useScreenshot.ts @@ -0,0 +1,77 @@ +import { useCallback } from 'react'; +import * as THREE from 'three'; + +export function useScreenshot() { + const takeScreenshot = useCallback(() => { + // 1. Find the Three.js Canvas + const canvas = document.querySelector('canvas'); + if (!canvas) { + console.error('Canvas not found'); + return; + } + + try { + // 2. Create a temporary 2D canvas for compositing + const tempCanvas = document.createElement('canvas'); + const width = canvas.width; + const height = canvas.height; + tempCanvas.width = width; + tempCanvas.height = height; + const ctx = tempCanvas.getContext('2d'); + + if (!ctx) return; + + // 3. Draw the 3D scene onto the temp canvas + ctx.drawImage(canvas, 0, 0, width, height); + + // 4. Get current camera info (approximate from scene if possible, or just current date) + const now = new Date(); + const dateStr = now.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }); + const timeStr = now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + + // We can try to get camera position if we had access to the store, + // but for now let's stick to generic data or we can grab it from global window if exposed + // Better: just show the Date and App Name. + + // 5. Add Overlay / Watermark + // Background gradient for text + const gradient = ctx.createLinearGradient(0, height - 100, 0, height); + gradient.addColorStop(0, 'transparent'); + gradient.addColorStop(1, 'rgba(0,0,0,0.8)'); + ctx.fillStyle = gradient; + ctx.fillRect(0, height - 150, width, 150); + + // App Name + ctx.font = 'bold 32px sans-serif'; + ctx.fillStyle = '#ffffff'; + ctx.textAlign = 'right'; + ctx.fillText('Cosmo', width - 40, height - 60); + + ctx.font = '16px sans-serif'; + ctx.fillStyle = '#aaaaaa'; + ctx.fillText('DEEP SPACE EXPLORER', width - 40, height - 35); + + // Date/Time + ctx.textAlign = 'left'; + ctx.font = 'bold 24px monospace'; + ctx.fillStyle = '#44aaff'; + ctx.fillText(dateStr, 40, height - 60); + + ctx.font = '18px monospace'; + ctx.fillStyle = '#cccccc'; + ctx.fillText(timeStr, 40, height - 35); + + // 6. Trigger Download + const dataUrl = tempCanvas.toDataURL('image/png'); + const link = document.createElement('a'); + link.download = `Cosmo_Snapshot_${now.toISOString().slice(0,19).replace(/[:T]/g, '-')}.png`; + link.href = dataUrl; + link.click(); + + } catch (err) { + console.error('Screenshot failed:', err); + } + }, []); + + return { takeScreenshot }; +} diff --git a/frontend/src/hooks/useSpaceData.ts b/frontend/src/hooks/useSpaceData.ts index 43e5c5e..724adb2 100644 --- a/frontend/src/hooks/useSpaceData.ts +++ b/frontend/src/hooks/useSpaceData.ts @@ -16,10 +16,10 @@ export function useSpaceData() { setLoading(true); setError(null); - // Fetch current position - single point in time at current hour - // Round to current hour (00 minutes, 00 seconds) + // Fetch current position - single point in time for today (UTC midnight) + // This ensures we hit the cache if we already have data for today const now = new Date(); - now.setMinutes(0, 0, 0); + now.setUTCHours(0, 0, 0, 0); const data = await fetchCelestialPositions( now.toISOString(), diff --git a/frontend/src/utils/renderPosition.ts b/frontend/src/utils/renderPosition.ts index 59d5c3a..f522d4f 100644 --- a/frontend/src/utils/renderPosition.ts +++ b/frontend/src/utils/renderPosition.ts @@ -8,47 +8,64 @@ import type { CelestialBody } from '../types'; /** * Calculate rendering position using true scaled coordinates - * For satellites, add a radial offset to avoid being hidden by parent planet + * For satellites, add a radial offset relative to PARENT PLANET */ export function calculateRenderPosition( body: CelestialBody, allBodies: CelestialBody[] -): { x: number; y: number; z: number } { +): { x: number; y: number; z: number; hasOffset: boolean } { const pos = body.positions[0]; if (!pos) { - return { x: 0, y: 0, z: 0 }; + return { x: 0, y: 0, z: 0, hasOffset: false }; } - // Use improved scaling that handles near-Earth objects properly + // 1. Calculate base scaled position (fallback) const scaled = scalePosition(pos.x, pos.y, pos.z); - // For satellites, add a radial offset to separate from parent planet + // 2. Special handling for satellites (like Moon) if (body.type === 'satellite') { - const distance = Math.sqrt(scaled.x ** 2 + scaled.y ** 2 + scaled.z ** 2); - if (distance > 0) { - // Add fixed offset: push satellite 1.2 units further from Sun - // This ensures it's always visible outside the parent planet's visual radius - const fixedOffset = 1.05; - const direction = { - x: scaled.x / distance, - y: scaled.y / distance, - z: scaled.z / distance - }; + const parent = findParentPlanet(body, allBodies); + if (parent) { + const parentPos = parent.positions[0]; + // Calculate parent's scaled position (where it is rendered) + // Note: Recursion is possible if parent is also a satellite, but unlikely for planets + const parentScaled = scalePosition(parentPos.x, parentPos.y, parentPos.z); + + // Calculate vector from Parent to Satellite (in True AU coordinates) + const dx = pos.x - parentPos.x; + const dy = pos.y - parentPos.y; + const dz = pos.z - parentPos.z; + + const dist = Math.sqrt(dx*dx + dy*dy + dz*dz); + + if (dist > 0) { + // Normalize the direction vector + const nx = dx / dist; + const ny = dy / dist; + const nz = dz / dist; - return { - x: scaled.x + direction.x * fixedOffset, - y: scaled.y + direction.y * fixedOffset, - z: scaled.z + direction.z * fixedOffset - }; + // Set a fixed visual offset distance + // Earth radius is 0.6, Moon is 0.15. + // A distance of 1.5 units ensures they are close but distinct and don't overlap. + // This simulates a "mini-system" view at the planet's location. + const visualOffset = 1.5; + + return { + x: parentScaled.x + nx * visualOffset, + y: parentScaled.y + ny * visualOffset, + z: parentScaled.z + nz * visualOffset, + hasOffset: true + }; + } } } - return { x: scaled.x, y: scaled.y, z: scaled.z }; + return { x: scaled.x, y: scaled.y, z: scaled.z, hasOffset: false }; } /** * Find the parent planet for a celestial body (e.g., Moon orbits Earth) - * Returns the planet that this body is closest to, if within a threshold + * Returns the planet that this body is closest to */ export function findParentPlanet( body: CelestialBody, @@ -57,6 +74,7 @@ export function findParentPlanet( const pos = body.positions[0]; if (!pos) return null; + // Look for planets or dwarf planets const planets = allBodies.filter(b => b.type === 'planet' || b.type === 'dwarf_planet'); let closestPlanet: CelestialBody | null = null; @@ -72,8 +90,10 @@ export function findParentPlanet( Math.pow(pos.z - planetPos.z, 2) ); - // Consider as "near" if within 0.05 AU (~7.5 million km) - if (distance < 0.05 && distance < minDistance) { + // Consider as "parent" if within 0.1 AU + // Moon is ~0.0026 AU from Earth + // Io is ~0.0028 AU from Jupiter + if (distance < 0.1 && distance < minDistance) { closestPlanet = planet; minDistance = distance; } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 5a699df..155762d 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2271,6 +2271,11 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +lucide-react@^0.555.0: + version "0.555.0" + resolved "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.555.0.tgz#8db4322b784e0d14a415d67ee0e8ca219a1714ca" + integrity sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA== + maath@^0.10.8: version "0.10.8" resolved "https://registry.npmmirror.com/maath/-/maath-0.10.8.tgz#cf647544430141bf6982da6e878abb6c4b804e24"