0.9.9
parent
e796eceaf8
commit
7037628831
|
|
@ -96,7 +96,7 @@ function App() {
|
|||
// Filter probes and planets from all bodies
|
||||
const probes = bodies.filter((b) => b.type === 'probe');
|
||||
const planets = bodies.filter((b) =>
|
||||
b.type === 'planet' || b.type === 'dwarf_planet' || b.type === 'satellite'
|
||||
b.type === 'planet' || b.type === 'dwarf_planet' || b.type === 'satellite' || b.type === 'comet'
|
||||
);
|
||||
|
||||
const handleBodySelect = (body: CelestialBody | null) => {
|
||||
|
|
@ -138,7 +138,7 @@ function App() {
|
|||
<div className="w-full h-full relative">
|
||||
{/* Header with simplified branding and User Auth */}
|
||||
<Header
|
||||
bodyCount={bodies.length}
|
||||
bodyCount={bodies.filter(b => b.is_active !== false).length}
|
||||
selectedBodyName={selectedBody?.name}
|
||||
user={user}
|
||||
onOpenAuth={() => setShowAuthModal(true)}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ import { useTexture, Html } from '@react-three/drei';
|
|||
import type { CelestialBody as CelestialBodyType } from '../types';
|
||||
import { calculateRenderPosition, getOffsetDescription } from '../utils/renderPosition';
|
||||
import { fetchBodyResources } from '../utils/api';
|
||||
import { PLANET_SIZES, SATELLITE_SIZES, getCelestialSize } from '../config/celestialSizes';
|
||||
|
||||
interface CelestialBodyProps {
|
||||
body: CelestialBodyType;
|
||||
allBodies: CelestialBodyType[];
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
// Saturn Rings component - multiple rings for band effect
|
||||
|
|
@ -73,12 +75,13 @@ function SaturnRings() {
|
|||
}
|
||||
|
||||
// Planet component with texture
|
||||
function Planet({ body, size, emissive, emissiveIntensity, allBodies }: {
|
||||
function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected = false }: {
|
||||
body: CelestialBodyType;
|
||||
size: number;
|
||||
emissive: string;
|
||||
emissiveIntensity: number;
|
||||
allBodies: CelestialBodyType[];
|
||||
isSelected?: boolean;
|
||||
}) {
|
||||
const meshRef = useRef<Mesh>(null);
|
||||
const position = body.positions[0];
|
||||
|
|
@ -132,11 +135,12 @@ function Planet({ body, size, emissive, emissiveIntensity, allBodies }: {
|
|||
meshRef={meshRef}
|
||||
hasOffset={renderPosition.hasOffset}
|
||||
allBodies={allBodies}
|
||||
isSelected={isSelected}
|
||||
/>;
|
||||
}
|
||||
|
||||
// Separate component to handle texture loading
|
||||
function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, texturePath, position, meshRef, hasOffset, allBodies }: {
|
||||
function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, texturePath, position, meshRef, hasOffset, allBodies, isSelected = false }: {
|
||||
body: CelestialBodyType;
|
||||
size: number;
|
||||
emissive: string;
|
||||
|
|
@ -147,6 +151,7 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
|
|||
meshRef: React.RefObject<Mesh>;
|
||||
hasOffset: boolean;
|
||||
allBodies: CelestialBodyType[];
|
||||
isSelected?: boolean;
|
||||
}) {
|
||||
// Load texture if path is provided
|
||||
const texture = texturePath ? useTexture(texturePath) : null;
|
||||
|
|
@ -212,25 +217,27 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
|
|||
distanceFactor={10}
|
||||
style={{
|
||||
color: body.type === 'star' ? '#FDB813' : '#ffffff',
|
||||
fontSize: '11px',
|
||||
fontSize: '9px', // 从 11px 减小到 9px
|
||||
fontWeight: 'bold',
|
||||
textShadow: '0 0 4px rgba(0,0,0,0.8)',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: isSelected ? 1 : 0.4, // 未选中时更暗淡
|
||||
transition: 'opacity 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{body.name_zh || body.name}
|
||||
{offsetDesc && (
|
||||
<>
|
||||
<br />
|
||||
<span style={{ fontSize: '9px', color: '#ffaa00', opacity: 0.9 }}>
|
||||
<span style={{ fontSize: '7px', color: '#ffaa00', opacity: isSelected ? 0.9 : 0.5 }}> {/* 从 9px 减小到 7px */}
|
||||
{offsetDesc}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<br />
|
||||
<span style={{ fontSize: '8px', opacity: 0.7 }}>
|
||||
<span style={{ fontSize: '7px', opacity: isSelected ? 0.7 : 0.3 }}> {/* 从 8px 减小到 7px */}
|
||||
{distance.toFixed(2)} AU
|
||||
</span>
|
||||
</Html>
|
||||
|
|
@ -238,7 +245,7 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
|
|||
);
|
||||
}
|
||||
|
||||
export function CelestialBody({ body, allBodies }: CelestialBodyProps) {
|
||||
export function CelestialBody({ body, allBodies, isSelected = false }: CelestialBodyProps) {
|
||||
// Get the current position (use the first position for now)
|
||||
const position = body.positions[0];
|
||||
if (!position) return null;
|
||||
|
|
@ -252,7 +259,7 @@ export function CelestialBody({ body, allBodies }: CelestialBodyProps) {
|
|||
const appearance = useMemo(() => {
|
||||
if (body.type === 'star') {
|
||||
return {
|
||||
size: 0.4, // Revert to original size
|
||||
size: 0.4, // Sun size
|
||||
emissive: '#FDB813',
|
||||
emissiveIntensity: 1.5,
|
||||
};
|
||||
|
|
@ -260,32 +267,16 @@ export function CelestialBody({ body, allBodies }: CelestialBodyProps) {
|
|||
|
||||
// Satellite (natural moons) - small size with slight glow for visibility
|
||||
if (body.type === 'satellite') {
|
||||
const satelliteSizes: Record<string, number> = {
|
||||
Moon: 0.15, // Small but visible
|
||||
// Add other satellites here as needed
|
||||
};
|
||||
return {
|
||||
size: satelliteSizes[body.name] || 0.12,
|
||||
size: getCelestialSize(body.name, body.type),
|
||||
emissive: '#888888', // Slight glow to make it visible
|
||||
emissiveIntensity: 0.4,
|
||||
};
|
||||
}
|
||||
|
||||
// Planet sizes - balanced for visibility with smaller probes
|
||||
const planetSizes: Record<string, number> = {
|
||||
Mercury: 0.35, // Slightly larger for visibility
|
||||
Venus: 0.55, // Slightly larger for visibility
|
||||
Earth: 0.6, // Slightly larger for visibility
|
||||
Mars: 0.45, // Slightly larger for visibility
|
||||
Jupiter: 1.4, // Larger gas giant
|
||||
Saturn: 1.2, // Larger gas giant
|
||||
Uranus: 0.8, // Medium outer planet
|
||||
Neptune: 0.8, // Medium outer planet
|
||||
Pluto: 0.2, // Dwarf planet, smaller than Moon
|
||||
};
|
||||
|
||||
// Planet and dwarf planet sizes
|
||||
return {
|
||||
size: planetSizes[body.name] || 0.5,
|
||||
size: getCelestialSize(body.name, body.type),
|
||||
emissive: '#000000',
|
||||
emissiveIntensity: 0,
|
||||
};
|
||||
|
|
@ -298,6 +289,7 @@ export function CelestialBody({ body, allBodies }: CelestialBodyProps) {
|
|||
emissive={appearance.emissive}
|
||||
emissiveIntensity={appearance.emissiveIntensity}
|
||||
allBodies={allBodies}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export function Constellations() {
|
|||
{constellation.starPositions.map((pos, idx) => (
|
||||
<mesh key={`${constellation.name}-star-${idx}`} position={pos}>
|
||||
<sphereGeometry args={[15, 8, 8]} />
|
||||
<meshBasicMaterial color="#FFFFFF" transparent opacity={0.9} />
|
||||
<meshBasicMaterial color="#FFFFFF" transparent opacity={0.6} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
|
|
@ -104,9 +104,9 @@ export function Constellations() {
|
|||
key={`${constellation.name}-line-${idx}`}
|
||||
points={[segment.start, segment.end]}
|
||||
color="#4488FF"
|
||||
lineWidth={1.5}
|
||||
lineWidth={1}
|
||||
transparent
|
||||
opacity={0.5}
|
||||
opacity={0.3}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
@ -115,7 +115,7 @@ export function Constellations() {
|
|||
<Text
|
||||
fontSize={120}
|
||||
color="#6699FF"
|
||||
fillOpacity={0.8}
|
||||
fillOpacity={0.6}
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,17 @@ import { fetchBodyResources } from '../utils/api';
|
|||
interface ProbeProps {
|
||||
body: CelestialBody;
|
||||
allBodies: CelestialBody[];
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
// Separate component for each probe type to properly use hooks
|
||||
function ProbeModel({ body, modelPath, allBodies, onError }: {
|
||||
function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, resourceScale = 1.0 }: {
|
||||
body: CelestialBody;
|
||||
modelPath: string;
|
||||
allBodies: CelestialBody[];
|
||||
isSelected?: boolean;
|
||||
onError: () => void;
|
||||
resourceScale?: number;
|
||||
}) {
|
||||
const groupRef = useRef<Group>(null);
|
||||
const position = body.positions[0];
|
||||
|
|
@ -52,16 +55,17 @@ function ProbeModel({ body, modelPath, allBodies, onError }: {
|
|||
const maxDimension = Math.max(size.x, size.y, size.z);
|
||||
|
||||
// Target size for display (consistent visual size)
|
||||
const targetSize = 0.5; // Target visual size in scene units
|
||||
const targetSize = 0.35; // Balanced size for all probes
|
||||
|
||||
// Calculate scale factor
|
||||
const calculatedScale = maxDimension > 0 ? targetSize / maxDimension : 0.2;
|
||||
const calculatedScale = maxDimension > 0 ? targetSize / maxDimension : 0.3;
|
||||
|
||||
// Clamp scale to reasonable range
|
||||
const finalScale = Math.max(0.1, Math.min(2.0, calculatedScale));
|
||||
// Clamp scale to reasonable range - tighter range for consistency
|
||||
const finalScale = Math.max(0.2, Math.min(1.0, calculatedScale));
|
||||
|
||||
return finalScale;
|
||||
}, [scene, body.name]);
|
||||
// Apply custom scale from resource metadata
|
||||
return finalScale * resourceScale;
|
||||
}, [scene, body.name, resourceScale]);
|
||||
|
||||
// 4. Hook: Configured Scene
|
||||
const configuredScene = useMemo(() => {
|
||||
|
|
@ -128,25 +132,27 @@ function ProbeModel({ body, modelPath, allBodies, onError }: {
|
|||
distanceFactor={15}
|
||||
style={{
|
||||
color: '#00ffff',
|
||||
fontSize: '12px',
|
||||
fontSize: '9px', // 从 12px 减小到 9px
|
||||
fontWeight: 'bold',
|
||||
textShadow: '0 0 6px rgba(0,255,255,0.8)',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: isSelected ? 1 : 0.4, // 未选中时更暗淡
|
||||
transition: 'opacity 0.3s ease',
|
||||
}}
|
||||
>
|
||||
🛰️ {body.name_zh || body.name}
|
||||
{body.name_zh || body.name}
|
||||
{offsetDesc && (
|
||||
<>
|
||||
<br />
|
||||
<span style={{ fontSize: '10px', color: '#ffaa00', opacity: 0.9 }}>
|
||||
<span style={{ fontSize: '7px', color: '#ffaa00', opacity: isSelected ? 0.9 : 0.5 }}> {/* 从 10px 减小到 7px */}
|
||||
{offsetDesc}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<br />
|
||||
<span style={{ fontSize: '10px', opacity: 0.8 }}>
|
||||
<span style={{ fontSize: '7px', opacity: isSelected ? 0.8 : 0.3 }}> {/* 从 10px 减小到 7px */}
|
||||
{distance.toFixed(2)} AU
|
||||
</span>
|
||||
</Html>
|
||||
|
|
@ -155,7 +161,7 @@ function ProbeModel({ body, modelPath, allBodies, onError }: {
|
|||
}
|
||||
|
||||
// Fallback component when model is not available
|
||||
function ProbeFallback({ body, allBodies }: { body: CelestialBody; allBodies: CelestialBody[] }) {
|
||||
function ProbeFallback({ body, allBodies, isSelected = false }: { body: CelestialBody; allBodies: CelestialBody[]; isSelected?: boolean }) {
|
||||
const position = body.positions[0];
|
||||
|
||||
// Use smart render position calculation
|
||||
|
|
@ -185,25 +191,27 @@ function ProbeFallback({ body, allBodies }: { body: CelestialBody; allBodies: Ce
|
|||
distanceFactor={15}
|
||||
style={{
|
||||
color: '#ff6666',
|
||||
fontSize: '12px',
|
||||
fontSize: '9px', // 从 12px 减小到 9px
|
||||
fontWeight: 'bold',
|
||||
textShadow: '0 0 6px rgba(255,0,0,0.8)',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: isSelected ? 1 : 0.4, // 未选中时更暗淡
|
||||
transition: 'opacity 0.3s ease',
|
||||
}}
|
||||
>
|
||||
🛰️ {body.name_zh || body.name}
|
||||
{body.name_zh || body.name}
|
||||
{offsetDesc && (
|
||||
<>
|
||||
<br />
|
||||
<span style={{ fontSize: '10px', color: '#ffaa00', opacity: 0.9 }}>
|
||||
<span style={{ fontSize: '7px', color: '#ffaa00', opacity: isSelected ? 0.9 : 0.5 }}> {/* 从 10px 减小到 7px */}
|
||||
{offsetDesc}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<br />
|
||||
<span style={{ fontSize: '10px', opacity: 0.8 }}>
|
||||
<span style={{ fontSize: '7px', opacity: isSelected ? 0.8 : 0.3 }}> {/* 从 10px 减小到 7px */}
|
||||
{distance.toFixed(2)} AU
|
||||
</span>
|
||||
</Html>
|
||||
|
|
@ -211,10 +219,11 @@ function ProbeFallback({ body, allBodies }: { body: CelestialBody; allBodies: Ce
|
|||
);
|
||||
}
|
||||
|
||||
export function Probe({ body, allBodies }: ProbeProps) {
|
||||
export function Probe({ body, allBodies, isSelected = false }: ProbeProps) {
|
||||
const position = body.positions[0];
|
||||
const [modelPath, setModelPath] = useState<string | null | undefined>(undefined);
|
||||
const [loadError, setLoadError] = useState<boolean>(false);
|
||||
const [resourceScale, setResourceScale] = useState<number>(1.0);
|
||||
|
||||
// Fetch model from backend API
|
||||
useEffect(() => {
|
||||
|
|
@ -227,6 +236,12 @@ export function Probe({ body, allBodies }: ProbeProps) {
|
|||
if (response.resources.length > 0) {
|
||||
// Get the first model resource
|
||||
const modelResource = response.resources[0];
|
||||
|
||||
// Get scale from extra_data
|
||||
const scale = modelResource.extra_data?.scale || 1.0;
|
||||
setResourceScale(scale);
|
||||
console.log(`[Probe ${body.name}] Scale from resource:`, scale);
|
||||
|
||||
// Construct full URL from file_path
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
|
|
@ -265,11 +280,11 @@ export function Probe({ body, allBodies }: ProbeProps) {
|
|||
|
||||
// Use model if available and no load error, otherwise use fallback
|
||||
if (modelPath && !loadError) {
|
||||
return <ProbeModel body={body} modelPath={modelPath} allBodies={allBodies} onError={() => {
|
||||
return <ProbeModel body={body} modelPath={modelPath} allBodies={allBodies} isSelected={isSelected} resourceScale={resourceScale} onError={() => {
|
||||
console.error(`[Probe ${body.name}] ProbeModel rendering failed, switching to fallback`);
|
||||
setLoadError(true);
|
||||
}} />;
|
||||
}
|
||||
|
||||
return <ProbeFallback body={body} allBodies={allBodies} />;
|
||||
return <ProbeFallback body={body} allBodies={allBodies} isSelected={isSelected} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, ChevronDown, ChevronUp, Search, Globe, Rocket, Moon, Asterisk } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, ChevronDown, ChevronUp, Search, Globe, Rocket, Moon, Asterisk, Sparkles } from 'lucide-react';
|
||||
import type { CelestialBody } from '../types';
|
||||
|
||||
interface ProbeListProps {
|
||||
|
|
@ -11,7 +11,7 @@ interface ProbeListProps {
|
|||
}
|
||||
|
||||
export function ProbeList({ probes, planets, onBodySelect, selectedBody, onResetCamera }: ProbeListProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(true); // 默认关闭
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [expandedGroup, setExpandedGroup] = useState<string | null>(null); // 只允许一个分组展开
|
||||
|
||||
|
|
@ -42,6 +42,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
|||
const dwarfPlanetList = processedBodies.filter(({ body }) => body.type === 'dwarf_planet');
|
||||
const satelliteList = processedBodies.filter(({ body }) => body.type === 'satellite');
|
||||
const probeList = processedBodies.filter(({ body }) => body.type === 'probe');
|
||||
const cometList = processedBodies.filter(({ body }) => body.type === 'comet');
|
||||
|
||||
const toggleGroup = (groupName: string) => {
|
||||
// 如果点击的是当前展开的分组,则收起;否则切换到新分组
|
||||
|
|
@ -147,6 +148,20 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Comets Group */}
|
||||
{cometList.length > 0 && (
|
||||
<BodyGroup
|
||||
title="彗星"
|
||||
icon={<Sparkles size={12} />}
|
||||
count={cometList.length}
|
||||
bodies={cometList}
|
||||
isExpanded={expandedGroup === 'comet'}
|
||||
onToggle={() => toggleGroup('comet')}
|
||||
selectedBody={selectedBody}
|
||||
onBodySelect={onBodySelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No results message */}
|
||||
{processedBodies.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 text-xs">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { OrbitControls, Stars as BackgroundStars, Html } from '@react-three/drei';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { CelestialBody } from './CelestialBody';
|
||||
import { Probe } from './Probe';
|
||||
import { CameraController } from './CameraController';
|
||||
|
|
@ -29,6 +29,16 @@ interface SceneProps {
|
|||
}
|
||||
|
||||
export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbits = true, onBodySelect, resetTrigger = 0 }: SceneProps) {
|
||||
// State to control info panel visibility (independent of selection)
|
||||
const [showInfoPanel, setShowInfoPanel] = useState(true);
|
||||
|
||||
// Reset info panel visibility when selected body changes
|
||||
useEffect(() => {
|
||||
if (selectedBody) {
|
||||
setShowInfoPanel(true);
|
||||
}
|
||||
}, [selectedBody]);
|
||||
|
||||
// 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');
|
||||
|
|
@ -116,7 +126,12 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
|
|||
|
||||
{/* Render all celestial bodies: planets, dwarf planets, satellites, and stars */}
|
||||
{celestialBodies.map((body) => (
|
||||
<CelestialBody key={body.id} body={body} allBodies={bodies} />
|
||||
<CelestialBody
|
||||
key={body.id}
|
||||
body={body}
|
||||
allBodies={bodies}
|
||||
isSelected={selectedBody?.id === body.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Unified orbit renderer for all celestial bodies (planets and dwarf planets) */}
|
||||
|
|
@ -124,7 +139,12 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
|
|||
|
||||
{/* Render visible probes with 3D models */}
|
||||
{visibleProbes.map((body) => (
|
||||
<Probe key={body.id} body={body} allBodies={bodies} />
|
||||
<Probe
|
||||
key={body.id}
|
||||
body={body}
|
||||
allBodies={bodies}
|
||||
isSelected={selectedBody?.id === body.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Render trajectory for selected probe */}
|
||||
|
|
@ -148,9 +168,9 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
|
|||
/>
|
||||
|
||||
{/* Dynamic Focus Info Label */}
|
||||
{selectedBody && onBodySelect && (
|
||||
{selectedBody && showInfoPanel && (
|
||||
<Html position={focusInfoPosition} center zIndexRange={[100, 0]}>
|
||||
<FocusInfo body={selectedBody} onClose={() => onBodySelect(null)} />
|
||||
<FocusInfo body={selectedBody} onClose={() => setShowInfoPanel(false)} />
|
||||
</Html>
|
||||
)}
|
||||
</Canvas>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* Celestial body rendering sizes configuration
|
||||
* Shared across components for consistent sizing
|
||||
*/
|
||||
|
||||
/**
|
||||
* Planet rendering sizes (radius in scene units)
|
||||
*/
|
||||
export const PLANET_SIZES: Record<string, number> = {
|
||||
Mercury: 0.35,
|
||||
Venus: 0.55,
|
||||
Earth: 0.6,
|
||||
Mars: 0.45,
|
||||
Jupiter: 1.4,
|
||||
Saturn: 1.2,
|
||||
Uranus: 0.8,
|
||||
Neptune: 0.8,
|
||||
Pluto: 0.2,
|
||||
// Default for unknown planets
|
||||
default: 0.5,
|
||||
};
|
||||
|
||||
/**
|
||||
* Satellite rendering sizes (radius in scene units)
|
||||
*/
|
||||
export const SATELLITE_SIZES: Record<string, number> = {
|
||||
Moon: 0.15,
|
||||
// Default for unknown satellites
|
||||
default: 0.12,
|
||||
};
|
||||
|
||||
/**
|
||||
* Star rendering sizes (radius in scene units)
|
||||
*/
|
||||
export const STAR_SIZES: Record<string, number> = {
|
||||
Sun: 0.4,
|
||||
// Default for unknown stars
|
||||
default: 0.4,
|
||||
};
|
||||
|
||||
/**
|
||||
* Comet rendering sizes (radius in scene units)
|
||||
* Comets are typically small with a bright nucleus
|
||||
*/
|
||||
export const COMET_SIZES: Record<string, number> = {
|
||||
// Famous comets
|
||||
Halley: 0.15,
|
||||
// Default for unknown comets
|
||||
default: 0.12,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the rendering size for a celestial body by name and type
|
||||
*/
|
||||
export function getCelestialSize(name: string, type: string): number {
|
||||
switch (type) {
|
||||
case 'planet':
|
||||
case 'dwarf_planet':
|
||||
return PLANET_SIZES[name] || PLANET_SIZES.default;
|
||||
case 'satellite':
|
||||
return SATELLITE_SIZES[name] || SATELLITE_SIZES.default;
|
||||
case 'star':
|
||||
return STAR_SIZES[name] || STAR_SIZES.default;
|
||||
case 'comet':
|
||||
return COMET_SIZES[name] || COMET_SIZES.default;
|
||||
default:
|
||||
return 0.5;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,9 +2,10 @@
|
|||
* Celestial Bodies Management Page
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { message, Modal, Form, Input, Select, Switch, InputNumber, Tag, Badge, Descriptions, Button, Space, Alert } from 'antd';
|
||||
import { CheckCircleOutlined, CloseCircleOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { message, Modal, Form, Input, Select, Switch, InputNumber, Tag, Badge, Descriptions, Button, Space, Alert, Upload, Popconfirm, Row, Col } from 'antd';
|
||||
import { CheckCircleOutlined, CloseCircleOutlined, SearchOutlined, UploadOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
import { DataTable } from '../../components/admin/DataTable';
|
||||
import { request } from '../../utils/request';
|
||||
|
||||
|
|
@ -35,6 +36,8 @@ export function CelestialBodies() {
|
|||
const [form] = Form.useForm();
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [refreshResources, setRefreshResources] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
|
@ -166,7 +169,7 @@ export function CelestialBodies() {
|
|||
const handleModalOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
|
||||
if (editingRecord) {
|
||||
// Update
|
||||
await request.put(`/celestial/${editingRecord.id}`, values);
|
||||
|
|
@ -176,7 +179,7 @@ export function CelestialBodies() {
|
|||
await request.post('/celestial/', values);
|
||||
message.success('创建成功');
|
||||
}
|
||||
|
||||
|
||||
setIsModalOpen(false);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
|
|
@ -185,6 +188,50 @@ export function CelestialBodies() {
|
|||
}
|
||||
};
|
||||
|
||||
// Handle resource upload
|
||||
const handleResourceUpload = async (file: File, resourceType: string) => {
|
||||
if (!editingRecord) {
|
||||
message.error('请先选择要编辑的天体');
|
||||
return false;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await request.post(
|
||||
`/celestial/resources/upload?body_id=${editingRecord.id}&resource_type=${resourceType}`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
message.success(`${response.data.message} (上传到 ${response.data.upload_directory} 目录)`);
|
||||
setRefreshResources(prev => prev + 1); // Trigger reload
|
||||
return false; // Prevent default upload behavior
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.detail || '上传失败');
|
||||
return false;
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle resource delete
|
||||
const handleResourceDelete = async (resourceId: number) => {
|
||||
try {
|
||||
await request.delete(`/celestial/resources/${resourceId}`);
|
||||
message.success('删除成功');
|
||||
setRefreshResources(prev => prev + 1); // Trigger reload
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.detail || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<CelestialBody> = [
|
||||
{
|
||||
title: 'ID',
|
||||
|
|
@ -214,6 +261,7 @@ export function CelestialBodies() {
|
|||
{ text: '卫星', value: 'satellite' },
|
||||
{ text: '探测器', value: 'probe' },
|
||||
{ text: '矮行星', value: 'dwarf_planet' },
|
||||
{ text: '彗星', value: 'comet' },
|
||||
],
|
||||
onFilter: (value, record) => record.type === value,
|
||||
render: (type: string) => {
|
||||
|
|
@ -223,6 +271,7 @@ export function CelestialBodies() {
|
|||
dwarf_planet: '矮行星',
|
||||
satellite: '卫星',
|
||||
probe: '探测器',
|
||||
comet: '彗星',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
},
|
||||
|
|
@ -319,90 +368,204 @@ export function CelestialBodies() {
|
|||
</>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
name="id"
|
||||
label="JPL Horizons ID"
|
||||
rules={[{ required: true, message: '请输入JPL Horizons ID' }]}
|
||||
>
|
||||
<Input disabled={!!editingRecord} placeholder="例如:-31 (Voyager 1) 或 399 (Earth)" />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="id"
|
||||
label="JPL Horizons ID"
|
||||
rules={[{ required: true, message: '请输入JPL Horizons ID' }]}
|
||||
>
|
||||
<Input disabled={!!editingRecord} placeholder="例如:-31 (Voyager 1) 或 399 (Earth)" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="type"
|
||||
label="类型"
|
||||
rules={[{ required: true, message: '请选择类型' }]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="planet">行星</Select.Option>
|
||||
<Select.Option value="dwarf_planet">矮行星</Select.Option>
|
||||
<Select.Option value="satellite">卫星</Select.Option>
|
||||
<Select.Option value="probe">探测器</Select.Option>
|
||||
<Select.Option value="star">恒星</Select.Option>
|
||||
<Select.Option value="comet">彗星</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="英文名"
|
||||
rules={[{ required: true, message: '请输入英文名' }]}
|
||||
>
|
||||
<Input placeholder="例如:Voyager 1" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="name_zh"
|
||||
label="中文名"
|
||||
>
|
||||
<Input placeholder="例如:旅行者1号" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="type"
|
||||
label="类型"
|
||||
rules={[{ required: true, message: '请选择类型' }]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="planet">行星</Select.Option>
|
||||
<Select.Option value="dwarf_planet">矮行星</Select.Option>
|
||||
<Select.Option value="satellite">卫星</Select.Option>
|
||||
<Select.Option value="probe">探测器</Select.Option>
|
||||
<Select.Option value="star">恒星</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="is_active"
|
||||
label="状态"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="英文名"
|
||||
rules={[{ required: true, message: '请输入英文名' }]}
|
||||
>
|
||||
<Input placeholder="例如:Voyager 1" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="name_zh"
|
||||
label="中文名"
|
||||
>
|
||||
<Input placeholder="例如:旅行者1号" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
|
||||
{editingRecord && editingRecord.has_resources && (
|
||||
<Form.Item label="已配置资源">
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
{Object.entries(editingRecord.resources || {}).map(([type, resources]) => (
|
||||
<Descriptions.Item
|
||||
key={type}
|
||||
label={
|
||||
type === 'texture' ? '纹理' :
|
||||
type === 'model' ? '模型' :
|
||||
type === 'icon' ? '图标' :
|
||||
type === 'thumbnail' ? '缩略图' :
|
||||
type === 'data' ? '数据' : type
|
||||
}
|
||||
>
|
||||
{resources.map((res: any, idx: number) => (
|
||||
<div key={res.id} style={{ marginBottom: idx < resources.length - 1 ? 8 : 0 }}>
|
||||
<Tag color="blue">{res.file_path}</Tag>
|
||||
<span style={{ fontSize: 12, color: '#888' }}>
|
||||
({(res.file_size / 1024).toFixed(2)} KB)
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#888' }}>
|
||||
提示:资源文件管理功能即将推出,敬请期待
|
||||
</div>
|
||||
</Form.Item>
|
||||
{editingRecord && (
|
||||
<ResourceManager
|
||||
bodyId={editingRecord.id}
|
||||
bodyType={editingRecord.type}
|
||||
resources={editingRecord.resources}
|
||||
hasResources={editingRecord.has_resources}
|
||||
onUpload={handleResourceUpload}
|
||||
onDelete={handleResourceDelete}
|
||||
uploading={uploading}
|
||||
refreshTrigger={refreshResources}
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Resource Manager Component
|
||||
function ResourceManager({
|
||||
bodyId,
|
||||
bodyType,
|
||||
resources,
|
||||
hasResources,
|
||||
onUpload,
|
||||
onDelete,
|
||||
uploading,
|
||||
refreshTrigger,
|
||||
}: {
|
||||
bodyId: string;
|
||||
bodyType: string;
|
||||
resources?: any;
|
||||
hasResources?: boolean;
|
||||
onUpload: (file: File, resourceType: string) => Promise<boolean>;
|
||||
onDelete: (resourceId: number) => Promise<void>;
|
||||
uploading: boolean;
|
||||
refreshTrigger: number;
|
||||
}) {
|
||||
const [currentResources, setCurrentResources] = useState(resources);
|
||||
|
||||
// Load resources when modal opens
|
||||
useEffect(() => {
|
||||
request.get(`/celestial/resources/${bodyId}`)
|
||||
.then(({ data }) => {
|
||||
const grouped: any = {};
|
||||
data.resources.forEach((res: any) => {
|
||||
if (!grouped[res.resource_type]) {
|
||||
grouped[res.resource_type] = [];
|
||||
}
|
||||
grouped[res.resource_type].push(res);
|
||||
});
|
||||
setCurrentResources(grouped);
|
||||
})
|
||||
.catch(() => {
|
||||
message.error('加载资源列表失败');
|
||||
});
|
||||
}, [refreshTrigger, bodyId]);
|
||||
|
||||
const resourceTypes = [
|
||||
{ key: 'texture', label: bodyType === 'probe' ? '纹理 (上传到 model 目录)' : '纹理 (上传到 texture 目录)' },
|
||||
{ key: 'model', label: bodyType === 'probe' ? '模型 (上传到 model 目录)' : '模型 (上传到 texture 目录)' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Form.Item label="资源配置">
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
{resourceTypes.map(({ key, label }) => (
|
||||
<div key={key}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>{label}</div>
|
||||
|
||||
<Upload
|
||||
beforeUpload={(file) => onUpload(file, key)}
|
||||
showUploadList={false}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={uploading} size="small">
|
||||
上传 {label.split(' ')[0]}
|
||||
</Button>
|
||||
</Upload>
|
||||
|
||||
{currentResources?.[key] && currentResources[key].length > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{currentResources[key].map((res: any) => (
|
||||
<div key={res.id} style={{ marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<Tag color="blue">{res.file_path}</Tag>
|
||||
<span style={{ fontSize: 12, color: '#888' }}>
|
||||
({(res.file_size / 1024).toFixed(2)} KB)
|
||||
</span>
|
||||
<Popconfirm
|
||||
title="确认删除?"
|
||||
onConfirm={() => onDelete(res.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
{key === 'model' && (
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
<Space size="small">
|
||||
<span style={{ fontSize: 12, color: '#666' }}>显示缩放:</span>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0.1}
|
||||
max={5}
|
||||
step={0.1}
|
||||
defaultValue={res.extra_data?.scale || 1.0}
|
||||
style={{ width: 80 }}
|
||||
placeholder="1.0"
|
||||
onChange={(value) => {
|
||||
// Update scale in resource
|
||||
const newScale = value || 1.0;
|
||||
request.put(`/celestial/resources/${res.id}`, {
|
||||
extra_data: { ...res.extra_data, scale: newScale }
|
||||
}).then(() => {
|
||||
message.success('缩放参数已更新');
|
||||
}).catch(() => {
|
||||
message.error('更新失败');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: '#999' }}>
|
||||
(推荐: Webb=0.3, 旅行者=1.5)
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* TypeScript type definitions for Cosmo application
|
||||
*/
|
||||
|
||||
export type CelestialBodyType = 'planet' | 'probe' | 'star' | 'dwarf_planet' | 'satellite';
|
||||
export type CelestialBodyType = 'planet' | 'probe' | 'star' | 'dwarf_planet' | 'satellite' | 'comet';
|
||||
|
||||
export interface Position {
|
||||
time: string;
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ export async function fetchBodyResources(bodyId: string, resourceType?: string):
|
|||
file_size: number;
|
||||
mime_type: string;
|
||||
created_at: string;
|
||||
extra_data?: Record<string, any>;
|
||||
}>;
|
||||
}> {
|
||||
const params: Record<string, string> = {};
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@
|
|||
*/
|
||||
|
||||
import { scalePosition } from './scaleDistance';
|
||||
import { getCelestialSize } from '../config/celestialSizes';
|
||||
import type { CelestialBody } from '../types';
|
||||
|
||||
/**
|
||||
* Calculate rendering position using true scaled coordinates
|
||||
* For satellites, add a radial offset relative to PARENT PLANET
|
||||
* For satellites and probes near planets, add a radial offset relative to PARENT PLANET
|
||||
*/
|
||||
export function calculateRenderPosition(
|
||||
body: CelestialBody,
|
||||
|
|
@ -22,33 +23,33 @@ export function calculateRenderPosition(
|
|||
// 1. Calculate base scaled position (fallback)
|
||||
const scaled = scalePosition(pos.x, pos.y, pos.z);
|
||||
|
||||
// 2. Special handling for satellites (like Moon)
|
||||
if (body.type === 'satellite') {
|
||||
// 2. Special handling for satellites (like Moon) and probes near planets
|
||||
if (body.type === 'satellite' || body.type === 'probe') {
|
||||
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)
|
||||
|
||||
// Calculate vector from Parent to body (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;
|
||||
|
||||
// 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;
|
||||
// Calculate dynamic offset based on parent planet's rendering size
|
||||
// Formula: planetRadius × 1.5 + 0.3 (fixed gap)
|
||||
// This ensures larger planets (Jupiter, Saturn) have larger offsets
|
||||
// while smaller planets (Earth, Mars) have smaller offsets
|
||||
const parentSize = getCelestialSize(parent.name, parent.type);
|
||||
const visualOffset = parentSize * 1.5 + 0.3;
|
||||
|
||||
return {
|
||||
x: parentScaled.x + nx * visualOffset,
|
||||
|
|
@ -106,7 +107,31 @@ export function findParentPlanet(
|
|||
* Get description for bodies that are close to planets
|
||||
*/
|
||||
export function getOffsetDescription(body: CelestialBody, allBodies: CelestialBody[]): string | null {
|
||||
// This function is kept for compatibility but no longer calculates offsets
|
||||
// Could be used to show proximity information in the future
|
||||
return null;
|
||||
if (body.type !== 'satellite' && body.type !== 'probe') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parent = findParentPlanet(body, allBodies);
|
||||
if (!parent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pos = body.positions[0];
|
||||
const parentPos = parent.positions[0];
|
||||
if (!pos || !parentPos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate actual distance
|
||||
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);
|
||||
|
||||
// Return proximity description
|
||||
const distanceStr = dist < 0.01
|
||||
? `${(dist * 149597870.7).toFixed(0)} km` // Convert AU to km for very close objects
|
||||
: `${dist.toFixed(4)} AU`;
|
||||
|
||||
return `近${parent.name_zh || parent.name} (${distanceStr})`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue