前端功能基本完成
parent
86d8b4cbd7
commit
4a22d48b19
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue