前端功能基本完成

main
mula.liu 2025-11-30 00:58:58 +08:00
parent 86d8b4cbd7
commit 4a22d48b19
21 changed files with 1017 additions and 445 deletions

View File

@ -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",

View File

@ -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<Date | null>(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 (
<div className="w-full h-full relative">
{/* Header with navigation and controls */}
{/* Header with simplified branding */}
<Header
isTimelineMode={isTimelineMode}
onToggleTimeline={toggleTimelineMode}
bodyCount={bodies.length}
selectedBodyName={selectedBody?.name}
/>
{/* Right Control Panel */}
<ControlPanel
isTimelineMode={isTimelineMode}
onToggleTimeline={toggleTimelineMode}
showOrbits={showOrbits}
onToggleOrbits={() => setShowOrbits(!showOrbits)}
isSoundOn={isSoundOn}
onToggleSound={() => setIsSoundOn(!isSoundOn)}
showDanmaku={showDanmaku}
onToggleDanmaku={() => setShowDanmaku(!showDanmaku)}
onLogin={() => navigate('/login')}
onScreenshot={takeScreenshot}
/>
{/* Probe List Sidebar */}
<ProbeList
probes={probes}
@ -100,6 +121,8 @@ function App() {
bodies={bodies}
selectedBody={selectedBody}
trajectoryPositions={trajectoryPositions}
showOrbits={showOrbits}
onBodySelect={handleBodySelect}
/>
{/* Timeline Controller */}
@ -111,23 +134,23 @@ function App() {
/>
)}
{/* Instructions overlay */}
<div className="absolute bottom-4 right-4 z-50 text-white text-xs bg-black bg-opacity-70 p-3 rounded">
{selectedBody ? (
<>
<p className="text-cyan-400 font-bold mb-2"></p>
<p>"返回太阳系视图"</p>
</>
) : (
<>
<p className="font-bold mb-2"></p>
<p>🖱 : </p>
<p>🖱 : </p>
<p>🖱 : </p>
<p className="mt-2 text-gray-400"></p>
</>
)}
</div>
{/* Interstellar Ticker Sound (Controlled) */}
<InterstellarTicker isPlaying={isSoundOn} />
{/* Instructions overlay (Only show when exploring freely) */}
{!selectedBody && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-30 text-white text-xs bg-black/50 backdrop-blur-sm border border-white/10 px-4 py-2 rounded-full flex items-center gap-4 pointer-events-none">
<div className="flex items-center gap-2">
<span className="bg-white/20 p-1 rounded text-[10px]"></span>
</div>
<div className="flex items-center gap-2">
<span className="bg-white/20 p-1 rounded text-[10px]"></span>
</div>
<div className="flex items-center gap-2">
<span className="bg-white/20 p-1 rounded text-[10px]"></span>
</div>
</div>
)}
</div>
);
}

View File

@ -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<THREE.Points>(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 (
<points ref={pointsRef} geometry={geometry}>
<pointsMaterial
size={size}
vertexColors
transparent
opacity={opacity}
sizeAttenuation={true}
depthWrite={false} // Prevent z-fighting with orbits
blending={THREE.AdditiveBlending}
/>
</points>
);
}
export function AsteroidBelts() {
const [asteroidBeltData, setAsteroidBeltData] = useState<BeltProps | null>(null);
const [kuiperBeltData, setKuiperBeltData] = useState<BeltProps | null>(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 (
<group>
{/* Main Asteroid Belt */}
{asteroidBeltData && (
<Belt {...asteroidBeltData} />
)}
{/* Kuiper Belt */}
{kuiperBeltData && (
<Belt {...kuiperBeltData} />
)}
</group>
);
}

View File

@ -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<string | null>(null);
const lastTargetPos = useRef<Vector3 | null>(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]);

View File

@ -192,7 +192,7 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
</mesh>
{/* Saturn Rings */}
{body.name === 'Saturn' && <SaturnRings />}
{body.id === '699' && <SaturnRings />}
{/* 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,
};

View File

@ -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) => (
<mesh key={`${constellation.name}-star-${idx}`} position={pos}>
<sphereGeometry args={[0.3, 8, 8]} />
<meshBasicMaterial color="#FFFFFF" transparent opacity={0.8} />
<sphereGeometry args={[15, 8, 8]} />
<meshBasicMaterial color="#FFFFFF" transparent opacity={0.9} />
</mesh>
))}
@ -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 */}
<Billboard position={constellation.center}>
<Text
fontSize={2}
color="#88AAFF"
fontSize={120}
color="#6699FF"
fillOpacity={0.8}
anchorX="center"
anchorY="middle"
outlineWidth={0.1}
outlineWidth={0}
outlineColor="#000000"
>
{constellation.nameZh}

View File

@ -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 (
<div className="absolute top-20 right-6 z-40 flex flex-col gap-3">
{/* Timeline Toggle */}
<button
onClick={onToggleTimeline}
className={buttonClass(isTimelineMode)}
>
<Calendar size={20} />
<div className={tooltipClass}>
{isTimelineMode ? '关闭时间轴' : '开启时间轴'}
</div>
</button>
{/* Orbit Toggle */}
<button
onClick={onToggleOrbits}
className={buttonClass(showOrbits)}
>
{showOrbits ? <Eye size={20} /> : <EyeOff size={20} />}
<div className={tooltipClass}>
{showOrbits ? '隐藏轨道' : '显示轨道'}
</div>
</button>
{/* Sound Toggle */}
<button
onClick={onToggleSound}
className={buttonClass(isSoundOn)}
>
{isSoundOn ? <Volume2 size={20} /> : <VolumeX size={20} />}
<div className={tooltipClass}>
{isSoundOn ? '关闭音效' : '开启音效'}
</div>
</button>
{/* Danmaku Toggle (Mock) */}
<button
onClick={onToggleDanmaku}
className={buttonClass(showDanmaku)}
>
<MessageSquare size={20} />
<div className={tooltipClass}>
{showDanmaku ? '关闭弹幕' : '开启弹幕'}
</div>
</button>
{/* Screenshot Button */}
<button
onClick={onScreenshot}
className="p-2 rounded-lg bg-white/10 text-gray-300 hover:bg-white/20 border border-white/5 transition-all duration-200 relative group"
>
<Camera size={20} />
<div className={tooltipClass}>
</div>
</button>
{/* Divider */}
<div className="h-px bg-white/10 w-full my-1"></div>
{/* Login Button */}
<button
onClick={onLogin}
className="p-2 rounded-lg bg-white/5 text-gray-300 hover:bg-white/10 border border-white/5 transition-all duration-200 relative group"
>
<LogIn size={20} />
<div className={tooltipClass}>
</div>
</button>
</div>
);
}

View File

@ -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)
<div className="flex flex-col items-center pointer-events-none -translate-y-24">
{/* 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">
{/* Close Button */}
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="absolute top-3 right-3 text-gray-400 hover:text-white transition-colors p-1 rounded-full hover:bg-white/10"
>
<X size={16} />
</button>
{/* Header */}
<div className="flex items-start justify-between mb-3 pr-6">
<div>
<div className="flex items-center gap-2 mb-1">
<h2 className="text-xl font-bold text-white tracking-tight">
{body.name_zh || body.name}
</h2>
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border ${
isProbe
? 'bg-purple-500/20 border-purple-500/40 text-purple-300'
: 'bg-blue-500/20 border-blue-500/40 text-blue-300'
}`}>
{isProbe ? '探测器' : '天体'}
</span>
</div>
<p className="text-xs text-gray-400 line-clamp-2 leading-relaxed">
{body.description || '暂无描述'}
</p>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-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} />
</div>
<div>
<div className="text-[9px] text-gray-500 uppercase"></div>
<div className="text-xs font-mono text-gray-200">{distance} AU</div>
</div>
</div>
{isProbe && (
<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 ${isActive ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>
<Activity size={14} />
</div>
<div>
<div className="text-[9px] text-gray-500 uppercase"></div>
<div className={`text-xs font-medium ${isActive ? 'text-green-300' : 'text-red-300'}`}>
{isActive ? '运行中' : '已失效'}
</div>
</div>
</div>
)}
</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>
</div>
);
}

View File

@ -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 */}
<Billboard position={galaxy.position.clone().multiplyScalar(1.03)}>
<Text
fontSize={1.5}
fontSize={60} // Increased from 1.5
color="#DDAAFF"
anchorX="center"
anchorY="middle"
outlineWidth={0.1}
outlineWidth={2}
outlineColor="#000000"
>
{galaxy.name_zh}

View File

@ -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 (
<>
<header className="absolute top-0 left-0 right-0 z-50 bg-gradient-to-b from-black via-black/80 to-transparent backdrop-blur-sm">
<div className="flex items-center justify-between px-6 py-4">
{/* Left: Logo and Title */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
<span className="text-2xl">🌌</span>
</div>
<div>
<h1 className="text-2xl font-bold text-white tracking-tight">Cosmo</h1>
<p className="text-xs text-gray-400"></p>
</div>
</div>
{/* Status Info */}
<div className="ml-6 px-3 py-1.5 bg-white/5 rounded-lg border border-white/10">
<p className="text-xs text-gray-300">
{selectedBodyName ? (
<>
<span className="text-cyan-400"></span> : <span className="text-white font-medium">{selectedBodyName}</span>
</>
) : (
<>
<span className="text-green-400"></span> {bodyCount}
</>
)}
</p>
</div>
</div>
{/* Right: Controls */}
<header className="absolute top-0 left-0 right-0 z-50 pointer-events-none">
<div className="px-6 py-4 bg-gradient-to-b from-black/90 via-black/60 to-transparent">
<div className="flex items-center gap-4 pointer-events-auto inline-flex">
<div className="flex items-center gap-3">
{/* Timeline Mode Toggle */}
<button
onClick={onToggleTimeline}
className={`
group relative px-4 py-2 rounded-lg font-medium text-sm transition-all duration-200
${
isTimelineMode
? 'bg-blue-600 text-white border-2 border-blue-400 shadow-lg shadow-blue-500/50'
: 'bg-white/5 text-gray-300 border border-white/10 hover:bg-white/10 hover:border-white/20'
}
`}
>
<div className="flex items-center gap-2">
<span className="text-lg">📅</span>
<span>{isTimelineMode ? '时间轴已启用' : '启用时间轴'}</span>
</div>
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
<span className="text-2xl">🌌</span>
</div>
<div>
<h1 className="text-2xl font-bold text-white tracking-tight drop-shadow-md">Cosmo</h1>
<p className="text-xs text-gray-400 font-medium tracking-wide">DEEP SPACE EXPLORER</p>
</div>
</div>
{/* Tooltip */}
{!isTimelineMode && (
<div className="absolute top-full right-0 mt-2 px-3 py-2 bg-gray-900 text-white text-xs rounded-lg shadow-xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap">
</div>
{/* Status Info */}
<div className="ml-4 px-3 py-1 bg-white/10 backdrop-blur-md rounded-full border border-white/10">
<p className="text-xs text-gray-300">
{selectedBodyName ? (
<>
<span className="text-cyan-400 animate-pulse"></span> : <span className="text-white font-bold ml-1">{selectedBodyName}</span>
</>
) : (
<>
<span className="text-green-400"></span> {bodyCount} 线
</>
)}
</button>
{/* Login Button */}
<button
onClick={() => setShowLoginModal(true)}
className="px-4 py-2 rounded-lg font-medium text-sm bg-white/5 text-gray-300 hover:bg-white/10 border border-white/10 transition-all duration-200"
>
<div className="flex items-center gap-2">
<span className="text-lg">👤</span>
<span></span>
</div>
</button>
</p>
</div>
</div>
</header>
{/* Login Modal */}
{showLoginModal && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm"
onClick={() => setShowLoginModal(false)}
>
<div
className="bg-gray-900 rounded-2xl p-8 max-w-md w-full mx-4 shadow-2xl border border-gray-700"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-white"> Cosmo</h2>
<button
onClick={() => setShowLoginModal(false)}
className="text-gray-400 hover:text-white transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
</label>
<input
type="text"
placeholder="请输入邮箱或用户名"
className="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
</label>
<input
type="password"
placeholder="请输入密码"
className="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 transition-colors"
/>
</div>
<div className="flex items-center justify-between text-sm">
<label className="flex items-center text-gray-300 cursor-pointer">
<input type="checkbox" className="mr-2 rounded" />
</label>
<a href="#" className="text-blue-400 hover:text-blue-300">
?
</a>
</div>
<button className="w-full py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg font-medium hover:from-blue-700 hover:to-blue-800 transition-all duration-200">
</button>
<div className="text-center text-sm text-gray-400">
{' '}
<a href="#" className="text-blue-400 hover:text-blue-300">
</a>
</div>
</div>
</div>
</div>
)}
</>
</div>
</header>
);
}
}

View File

@ -0,0 +1,106 @@
import { useEffect, useRef, useCallback } from 'react';
interface InterstellarTickerProps {
isPlaying: boolean;
}
export function InterstellarTicker({ isPlaying }: InterstellarTickerProps) {
const audioContextRef = useRef<AudioContext | null>(null);
const timerRef = useRef<number | null>(null);
const nextNoteTimeRef = useRef<number>(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;
}

View File

@ -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 */}
<Billboard position={nebula.position.clone().multiplyScalar(1.02)}>
<Text
fontSize={1.2}
fontSize={60} // Increased font size from 1.2
color="#FFAADD"
anchorX="center"
anchorY="middle"
outlineWidth={0.1}
outlineWidth={2}
outlineColor="#000000"
>
{nebula.name_zh}

View File

@ -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<OrbitData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -109,7 +113,7 @@ export function OrbitRenderer() {
}
return (
<group>
<group visible={visible}>
{orbits.map((orbit) => (
<Line
key={orbit.bodyId}

View File

@ -1,7 +1,5 @@
/**
* ProbeList component - sidebar showing planets and probes
*/
import { useState } from 'react';
import { ChevronLeft, ChevronRight, Search, Globe, Rocket } from 'lucide-react';
import type { CelestialBody } from '../types';
interface ProbeListProps {
@ -12,152 +10,163 @@ interface ProbeListProps {
}
export function ProbeList({ probes, planets, onBodySelect, selectedBody }: ProbeListProps) {
const [isExpanded, setIsExpanded] = useState(true);
const [isCollapsed, setIsCollapsed] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
// Calculate distance for each probe
const probesWithDistance = probes.map((probe) => {
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 (
<button
onClick={() => setIsExpanded(true)}
className="absolute top-24 left-4 z-50 bg-black bg-opacity-80 backdrop-blur-sm rounded-lg p-3 hover:bg-opacity-90 transition-all"
title="展开天体列表"
>
<div className="flex items-center gap-2 text-white">
<span className="text-lg">🌍</span>
<span className="text-sm font-medium"></span>
</div>
</button>
);
}
const planetList = processBodies(planets);
const probeList = processBodies(probes);
return (
<div className="absolute top-24 left-4 z-50 bg-black bg-opacity-80 backdrop-blur-sm rounded-lg p-4 max-w-xs">
<div className="flex items-center justify-between mb-3">
<h2 className="text-white text-lg font-bold flex items-center gap-2">
🌍
</h2>
<button
onClick={() => setIsExpanded(false)}
className="text-gray-400 hover:text-white transition-colors text-sm"
title="收起列表"
>
</button>
</div>
{/* Planets Section */}
<div className="mb-4">
<h3 className="text-white text-sm font-semibold mb-2 text-gray-300">
({planetsWithDistance.length})
</h3>
{planetsWithDistance.length === 0 ? (
<div className="text-gray-500 text-xs p-2">...</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto">
{planetsWithDistance.map(({ body, distance }) => (
<button
key={body.id}
onClick={() => onBodySelect(body)}
className={`w-full text-left p-2 rounded transition-all ${
selectedBody?.id === body.id
? 'bg-blue-500 bg-opacity-30 border-2 border-blue-400'
: 'bg-gray-800 bg-opacity-50 border border-gray-600 hover:bg-gray-700'
}`}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="text-white font-medium text-sm">{body.name_zh || body.name}</div>
<div className="text-gray-400 text-xs mt-0.5">
{distance.toFixed(2)} AU
</div>
</div>
{selectedBody?.id === body.id && (
<div className="text-blue-400 text-xs"> </div>
)}
</div>
</button>
))}
<div
className={`
absolute top-24 left-4 bottom-8 z-40
transition-all duration-300 ease-in-out flex
${isCollapsed ? 'w-12' : 'w-64'} // Adjusted width
`}
>
{/* Main Content Panel */}
<div className={`
flex-1 bg-black/80 backdrop-blur-md border border-white/10 rounded-2xl overflow-hidden flex flex-col
transition-opacity duration-300
${isCollapsed ? 'opacity-0 pointer-events-none' : 'opacity-100'}
`}>
{/* Header & Search */}
<div className="p-4 border-b border-white/10 space-y-3">
<div className="flex items-center justify-between text-white">
<h2 className="font-bold text-base tracking-wide"></h2> {/* text-lg -> text-base */}
<button
onClick={() => onBodySelect(null)}
className="text-xs bg-white/10 hover:bg-white/20 px-2 py-1 rounded transition-colors text-gray-300"
>
</button>
</div>
)}
</div>
{/* Probes Section */}
<div>
<h3 className="text-white text-sm font-semibold mb-2 text-gray-300">
({probesWithDistance.length})
</h3>
{probesWithDistance.length === 0 ? (
<div className="text-gray-500 text-xs p-2">...</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto">
{probesWithDistance.map(({ body, distance }) => {
const isInactive = body.is_active === false;
const isSelected = selectedBody?.id === body.id;
return (
<button
key={body.id}
onClick={() => !isInactive && onBodySelect(body)}
disabled={isInactive}
className={`w-full text-left p-2 rounded transition-all ${
isInactive
? 'bg-gray-900 bg-opacity-50 border border-gray-700 cursor-not-allowed opacity-50'
: isSelected
? 'bg-cyan-500 bg-opacity-30 border-2 border-cyan-400'
: 'bg-gray-800 bg-opacity-50 border border-gray-600 hover:bg-gray-700'
}`}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className={`font-medium text-sm ${isInactive ? 'text-gray-500' : 'text-white'}`}>
{body.name_zh || body.name}
{isInactive && <span className="ml-2 text-xs">()</span>}
</div>
<div className={`text-xs mt-0.5 ${isInactive ? 'text-gray-600' : 'text-gray-400'}`}>
{distance.toFixed(2)} AU
{distance > 30 && ' (遥远)'}
</div>
</div>
{isSelected && !isInactive && (
<div className="text-cyan-400 text-xs"> </div>
)}
</div>
</button>
);
})}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={14} />
<input
type="text"
placeholder="搜索天体..."
value={searchTerm}
onChange={(e) => 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"
/>
</div>
)}
</div>
{/* List Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-4">
{/* Planets Group */}
<div>
<div className="px-2 mb-2 flex items-center gap-2 text-[10px] font-bold text-gray-400 uppercase tracking-wider"> {/* text-xs -> text-[10px] */}
<Globe size={12} />
({planetList.length})
</div>
<div className="space-y-1">
{planetList.map(({ body, distance }) => (
<BodyItem
key={body.id}
body={body}
distance={distance}
isSelected={selectedBody?.id === body.id}
onClick={() => onBodySelect(body)}
/>
))}
</div>
</div>
{/* Probes Group */}
<div>
<div className="px-2 mb-2 flex items-center gap-2 text-[10px] font-bold text-gray-400 uppercase tracking-wider"> {/* text-xs -> text-[10px] */}
<Rocket size={12} />
({probeList.length})
</div>
<div className="space-y-1">
{probeList.map(({ body, distance }) => (
<BodyItem
key={body.id}
body={body}
distance={distance}
isSelected={selectedBody?.id === body.id}
onClick={() => onBodySelect(body)}
/>
))}
</div>
</div>
</div>
</div>
{/* Return button */}
<div className="mt-3 pt-3 border-t border-gray-600">
<button
onClick={() => onBodySelect(null)}
className="w-full text-center p-2 rounded bg-gray-700 hover:bg-gray-600 text-white text-sm"
>
</button>
</div>
{/* Toggle Button (Attached to the side) */}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className={`
absolute top-0 ${isCollapsed ? 'left-0' : '-right-4'}
w-8 h-8 flex items-center justify-center
bg-black/80 backdrop-blur-md border border-white/10 rounded-full
text-white hover:bg-blue-600 transition-all shadow-lg z-50
${!isCollapsed && 'translate-x-1/2'}
`}
style={{ top: '20px' }}
>
{isCollapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</button>
</div>
);
}
function BodyItem({ body, distance, isSelected, onClick }: {
body: CelestialBody,
distance: number,
isSelected: boolean,
onClick: () => void
}) {
const isInactive = body.is_active === false;
return (
<button
onClick={isInactive ? undefined : onClick}
disabled={isInactive}
className={`
w-full flex items-center justify-between p-2 rounded-lg text-left transition-all duration-200 group
${isSelected
? 'bg-blue-600/20 border border-blue-500/50 shadow-[0_0_15px_rgba(37,99,235,0.2)]'
: isInactive
? 'opacity-40 cursor-not-allowed'
: 'hover:bg-white/10 border border-transparent'
}
`}
>
<div>
<div className={`text-xs font-medium ${isSelected ? 'text-blue-200' : 'text-gray-200 group-hover:text-white'}`}> {/* text-sm -> text-xs */}
{body.name_zh || body.name}
</div>
<div className="text-[9px] text-gray-500 font-mono"> {/* text-[10px] -> text-[9px] */}
{distance.toFixed(2)} AU
</div>
</div>
{isSelected && (
<div className="w-1.5 h-1.5 rounded-full bg-blue-400 shadow-[0_0_8px_rgba(96,165,250,0.8)] animate-pulse" />
)}
</button>
);
}

View File

@ -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 (
<div className="w-full h-full bg-black">
<Canvas
camera={{
position: [50, 40, 50], // Angled view of the ecliptic plane
fov: 70, // Wider field of view
position: [25, 20, 25], // Closer view to make solar system appear larger
fov: 60, // Slightly narrower FOV for less distortion
far: 20000, // Increased far plane for distant stars and constellations
}}
gl={{
alpha: false,
antialias: true,
preserveDrawingBuffer: false,
preserveDrawingBuffer: true, // Required for screenshots
}}
onCreated={({ gl, camera }) => {
gl.sortObjects = true; // Enable object sorting by renderOrder
@ -88,13 +106,16 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [] }: SceneP
{/* Distant galaxies */}
<Galaxies />
{/* Asteroid Belts & Kuiper Belt */}
<AsteroidBelts />
{/* Render all celestial bodies: planets, dwarf planets, satellites, and stars */}
{celestialBodies.map((body) => (
<CelestialBody key={body.id} body={body} allBodies={bodies} />
))}
{/* Unified orbit renderer for all celestial bodies (planets and dwarf planets) */}
<OrbitRenderer />
<OrbitRenderer visible={showOrbits} />
{/* 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 && (
<Html position={focusInfoPosition} center zIndexRange={[100, 0]}>
<FocusInfo body={selectedBody} onClose={() => onBodySelect(null)} />
</Html>
)}
</Canvas>
</div>
);

View File

@ -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 */}
<Billboard position={star.position.clone().multiplyScalar(1.05)}>
<Text
fontSize={1.2}
fontSize={40} // Increased font size
color="#FFFFFF"
anchorX="center"
anchorY="middle"
outlineWidth={0.08}
outlineWidth={2}
outlineColor="#000000"
>
{star.name_zh}

View File

@ -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 (
<div className="fixed bottom-20 left-1/2 transform -translate-x-1/2 z-50 bg-black bg-opacity-80 p-4 rounded-lg shadow-lg min-w-96">
{/* Current date display - full format */}
<div className="text-center text-white text-sm mb-2 font-mono">
{formatFullDate(currentDate)}
</div>
{/* Date range labels */}
<div className="flex justify-between text-white text-xs mb-1 px-1">
<span className="text-gray-400">{formatShortDate(startDate)}</span>
<span className="text-gray-400">{formatShortDate(endDate)}</span>
</div>
{/* Progress bar (standard slider) */}
<input
type="range"
min="0"
max="100"
step="0.1"
value={currentProgress}
onChange={handleSliderChange}
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer mb-3"
/>
{/* Controls */}
<div className="flex items-center justify-center gap-4">
{/* Play/Pause button */}
<button
type="button"
onClick={handlePlayPause}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
>
{isPlaying ? '⏸ 暂停' : '▶ 播放'}
</button>
{/* Speed control */}
<div className="flex items-center gap-2">
<span className="text-white text-xs">:</span>
<select
value={speed}
onChange={(e) => handleSpeedChange(Number(e.target.value))}
className="bg-gray-700 text-white px-2 py-1 rounded text-xs"
>
<option value="1">1x (1/)</option>
<option value="7">7x (1/)</option>
<option value="30">30x (1/)</option>
<option value="365">365x (1/)</option>
</select>
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 z-40 flex flex-col items-center w-full max-w-md px-4 pointer-events-none">
<div className="bg-black/80 backdrop-blur-md border border-white/10 rounded-2xl p-4 w-full shadow-2xl pointer-events-auto">
{/* Header: Date & Icon */}
<div className="flex items-center justify-between mb-3 text-white">
<div className="flex items-center gap-2 text-blue-400">
<CalendarClock size={18} />
<span className="text-xs font-bold uppercase tracking-wider"></span>
</div>
<div className="font-mono text-xl font-bold tracking-tight">
{formatFullDate(currentDate)}
</div>
</div>
{/* Reset button */}
<button
type="button"
onClick={() => {
setCurrentDate(new Date(startDate)); // Reset to start (now)
setIsPlaying(false);
}}
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-2 rounded text-xs"
>
</button>
{/* Progress Bar */}
<div className="relative mb-4 group">
<div className="flex justify-between text-[10px] text-gray-500 font-mono mb-1">
<span>{formatShortDate(startDate)} ()</span>
<span>{formatShortDate(endDate)} ()</span>
</div>
<input
type="range"
min="0"
max="100"
step="0.1"
value={currentProgress}
onChange={handleSliderChange}
className="w-full h-1.5 bg-gray-700 rounded-full appearance-none cursor-pointer hover:h-2 transition-all accent-blue-500"
/>
</div>
{/* Controls */}
<div className="flex items-center justify-between">
{/* Speed Selector */}
<div className="flex items-center gap-2 bg-white/5 rounded-lg p-1 border border-white/5">
<FastForward size={14} className="text-gray-400 ml-1" />
<select
value={speed}
onChange={(e) => handleSpeedChange(Number(e.target.value))}
className="bg-transparent text-white text-xs focus:outline-none cursor-pointer py-1 pr-1"
>
<option value="1">1/</option>
<option value="7">1/</option>
<option value="30">1/</option>
<option value="365">1/</option>
</select>
</div>
{/* Main Controls */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
setCurrentDate(new Date(startDate)); // Reset to start (now)
setIsPlaying(false);
}}
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-gray-300 transition-colors"
title="重置"
>
<RotateCcw size={16} />
</button>
<button
type="button"
onClick={handlePlayPause}
className={`
p-3 rounded-full text-white shadow-lg transition-all transform hover:scale-105 active:scale-95
${isPlaying
? 'bg-amber-500 hover:bg-amber-600 shadow-amber-500/30'
: 'bg-blue-600 hover:bg-blue-700 shadow-blue-600/30'
}
`}
title={isPlaying ? "暂停" : "播放"}
>
{isPlaying ? <Pause size={20} fill="currentColor" /> : <Play size={20} fill="currentColor" className="ml-0.5" />}
</button>
</div>
</div>
</div>
</div>
);

View File

@ -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 };
}

View File

@ -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(),

View File

@ -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;
}

View File

@ -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"