291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
/**
|
|
* Stars component - renders nearby stars in 3D space
|
|
*/
|
|
import { useEffect, useState, useMemo } from 'react';
|
|
import { Billboard, Html } from '@react-three/drei';
|
|
import * as THREE from 'three';
|
|
import { request } from '../utils/request';
|
|
import { createLabelTexture } from '../utils/labelTexture';
|
|
|
|
interface Star {
|
|
name: string;
|
|
name_zh: string;
|
|
distance_ly: number;
|
|
ra: number; // Right Ascension in degrees
|
|
dec: number; // Declination in degrees
|
|
magnitude: number;
|
|
color: string;
|
|
position: THREE.Vector3;
|
|
size: number;
|
|
rawData?: any; // Add optional rawData field
|
|
}
|
|
|
|
/**
|
|
* Convert RA/Dec to Cartesian coordinates
|
|
* RA: Right Ascension (0-360 degrees)
|
|
* Dec: Declination (-90 to 90 degrees)
|
|
* Distance: fixed distance for celestial sphere
|
|
*/
|
|
function raDecToCartesian(ra: number, dec: number, distance: number = 5000) {
|
|
// Convert to radians
|
|
const raRad = (ra * Math.PI) / 180;
|
|
const decRad = (dec * Math.PI) / 180;
|
|
|
|
// Convert to Cartesian coordinates
|
|
const x = distance * Math.cos(decRad) * Math.cos(raRad);
|
|
const y = distance * Math.cos(decRad) * Math.sin(raRad);
|
|
const z = distance * Math.sin(decRad);
|
|
|
|
return new THREE.Vector3(x, y, z);
|
|
}
|
|
|
|
/**
|
|
* Scale star brightness based on magnitude
|
|
* Lower magnitude = brighter star
|
|
*/
|
|
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(5, 20 - normalized * 1.2);
|
|
}
|
|
|
|
// Sub-component for individual star to handle label texture efficiently
|
|
function StarObject({ star, geometry, mode, onStarClick }: {
|
|
star: Star;
|
|
geometry: THREE.SphereGeometry;
|
|
mode: 'sky' | 'galaxy';
|
|
onStarClick?: (star: Star) => void;
|
|
}) {
|
|
const [hovered, setHovered] = useState(false);
|
|
|
|
// Generate label texture
|
|
const labelTexture = useMemo(() => {
|
|
// Use Chinese name if available, otherwise use English name
|
|
const displayName = star.name_zh || star.name;
|
|
return createLabelTexture(displayName, null, "", "#FFFFFF");
|
|
}, [star.name_zh, star.name]);
|
|
|
|
// Adjust visual parameters based on mode
|
|
const baseSize = mode === 'galaxy' ? star.size * 8 : star.size; // Make stars larger in galaxy mode
|
|
const visualSize = hovered ? baseSize * 1.5 : baseSize; // Scale up on hover
|
|
|
|
const labelScale: [number, number, number] = mode === 'galaxy' ? [50, 25, 1] : [200, 100, 1]; // Smaller labels in galaxy mode
|
|
const labelOffset = mode === 'galaxy' ? 4 : 1.05; // Offset factor
|
|
|
|
// Handle cursor
|
|
useEffect(() => {
|
|
document.body.style.cursor = hovered ? 'pointer' : 'auto';
|
|
return () => { document.body.style.cursor = 'auto'; };
|
|
}, [hovered]);
|
|
|
|
return (
|
|
<group
|
|
onClick={(e) => {
|
|
console.log('Star clicked:', star.name);
|
|
e.stopPropagation();
|
|
onStarClick?.(star);
|
|
}}
|
|
onPointerOver={(e) => {
|
|
console.log('Pointer over:', star.name);
|
|
e.stopPropagation();
|
|
setHovered(true);
|
|
}}
|
|
onPointerOut={(e) => {
|
|
e.stopPropagation();
|
|
setHovered(false);
|
|
}}
|
|
>
|
|
{/* Star sphere */}
|
|
<mesh position={star.position} geometry={geometry} scale={[visualSize, visualSize, visualSize]}>
|
|
<meshBasicMaterial
|
|
color={hovered ? '#ffaa00' : star.color} // Highlight color on hover
|
|
transparent
|
|
opacity={0.9}
|
|
blending={THREE.AdditiveBlending}
|
|
/>
|
|
</mesh>
|
|
|
|
{/* Star glow */}
|
|
<mesh position={star.position} geometry={geometry} scale={[visualSize * 2, visualSize * 2, visualSize * 2]}>
|
|
<meshBasicMaterial
|
|
color={hovered ? '#ffaa00' : star.color}
|
|
transparent
|
|
opacity={0.2}
|
|
blending={THREE.AdditiveBlending}
|
|
/>
|
|
</mesh>
|
|
|
|
{/* Star name label */}
|
|
{labelTexture && (
|
|
<Billboard position={mode === 'galaxy'
|
|
? new THREE.Vector3(star.position.x, star.position.y + visualSize + 2, star.position.z)
|
|
: star.position.clone().multiplyScalar(labelOffset)
|
|
}>
|
|
<mesh scale={labelScale} renderOrder={999}>
|
|
<planeGeometry />
|
|
<meshBasicMaterial
|
|
map={labelTexture}
|
|
transparent
|
|
opacity={hovered ? 1.0 : (mode === 'galaxy' ? 0.85 : 1.0)}
|
|
depthWrite={false}
|
|
depthTest={false}
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
</Billboard>
|
|
)}
|
|
|
|
{/* Hover Tooltip in Galaxy Mode */}
|
|
{mode === 'galaxy' && hovered && star.rawData && (
|
|
<Html
|
|
position={[star.position.x, star.position.y + visualSize + 8, star.position.z]}
|
|
center
|
|
style={{
|
|
pointerEvents: 'none',
|
|
userSelect: 'none',
|
|
}}
|
|
>
|
|
<div className="bg-black/90 border border-blue-400/50 rounded-lg px-4 py-3 text-white text-xs shadow-2xl backdrop-blur-sm min-w-[280px]">
|
|
<div className="font-bold text-sm mb-2 text-blue-300">{star.name_zh || star.name}</div>
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-400">距离:</span>
|
|
<span>{star.rawData.distance_pc?.toFixed(2) ?? 'N/A'} pc (~{star.distance_ly?.toFixed(2) ?? 'N/A'} ly)</span>
|
|
</div>
|
|
{star.rawData.spectral_type && (
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-400">光谱类型:</span>
|
|
<span>{star.rawData.spectral_type}</span>
|
|
</div>
|
|
)}
|
|
{star.rawData.radius_solar && (
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-400">半径:</span>
|
|
<span>{star.rawData.radius_solar.toFixed(2)} R☉</span>
|
|
</div>
|
|
)}
|
|
{star.rawData.mass_solar && (
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-400">质量:</span>
|
|
<span>{star.rawData.mass_solar.toFixed(2)} M☉</span>
|
|
</div>
|
|
)}
|
|
{star.rawData.temperature_k && (
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-400">温度:</span>
|
|
<span>{star.rawData.temperature_k.toFixed(0)} K</span>
|
|
</div>
|
|
)}
|
|
{star.rawData.star_count && (
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-400">恒星数:</span>
|
|
<span>{star.rawData.star_count.toFixed(0)} 颗</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="text-gray-500 text-[10px] mt-2 text-center">点击查看详细信息</div>
|
|
</div>
|
|
</Html>
|
|
)}
|
|
</group>
|
|
);
|
|
}
|
|
|
|
export function Stars({ mode = 'sky', onStarClick }: { mode?: 'sky' | 'galaxy'; onStarClick?: (star: Star) => void }) {
|
|
const [stars, setStars] = useState<Star[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (mode === 'galaxy') {
|
|
// Galaxy Mode: Load from new star-systems API
|
|
request.get('/star-systems', { params: { limit: 1000, exclude_solar: false } })
|
|
.then((res) => {
|
|
const { systems } = res.data;
|
|
// Process star systems data
|
|
const starData = systems
|
|
.filter((system: any) => system.position_x != null && system.position_y != null && system.position_z != null)
|
|
.map((system: any) => {
|
|
// Galaxy Mode: Use 3D coordinates from database (Parsecs)
|
|
// Scale factor to make the visualization comfortable
|
|
const SCALE = 100;
|
|
const position = new THREE.Vector3(
|
|
system.position_x * SCALE,
|
|
system.position_y * SCALE,
|
|
system.position_z * SCALE
|
|
);
|
|
|
|
// Calculate size based on radius_solar if available
|
|
const radius = system.radius_solar || 1.0;
|
|
const size = Math.max(0.5, Math.log(radius + 1) * 2);
|
|
|
|
// Calculate distance_ly if not provided
|
|
const distance_ly = system.distance_ly || (system.distance_pc ? system.distance_pc * 3.26 : null);
|
|
|
|
return {
|
|
name: system.name,
|
|
name_zh: system.name_zh,
|
|
distance_ly,
|
|
ra: system.ra,
|
|
dec: system.dec,
|
|
magnitude: system.magnitude,
|
|
color: system.color || '#ffffff',
|
|
position,
|
|
size,
|
|
// Pass complete system data for details view and hover tooltip
|
|
rawData: system
|
|
};
|
|
});
|
|
setStars(starData);
|
|
})
|
|
.catch((err) => console.error('Failed to load star systems:', err));
|
|
} else {
|
|
// Sky Mode: Load from static data API
|
|
request.get('/celestial/static/star')
|
|
.then((res) => {
|
|
const data = res.data;
|
|
const starData = data.items.map((item: any) => {
|
|
// Sky Mode: Project to Celestial Sphere
|
|
const position = raDecToCartesian(item.data.ra, item.data.dec, 5000);
|
|
const size = magnitudeToSize(item.data.magnitude);
|
|
|
|
return {
|
|
name: item.name,
|
|
name_zh: item.name_zh,
|
|
distance_ly: item.data.distance_ly || (item.data.distance_pc ? item.data.distance_pc * 3.26 : null),
|
|
ra: item.data.ra,
|
|
dec: item.data.dec,
|
|
magnitude: item.data.magnitude,
|
|
color: item.data.color,
|
|
position,
|
|
size,
|
|
rawData: item.data
|
|
};
|
|
});
|
|
setStars(starData);
|
|
})
|
|
.catch((err) => console.error('Failed to load stars:', err));
|
|
}
|
|
}, [mode]);
|
|
|
|
// Reuse geometry for all stars to improve performance
|
|
const sphereGeometry = useMemo(() => new THREE.SphereGeometry(1, 16, 16), []);
|
|
|
|
if (stars.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<group>
|
|
{stars.map((star) => (
|
|
<StarObject
|
|
key={star.name}
|
|
star={star}
|
|
geometry={sphereGeometry}
|
|
mode={mode}
|
|
onStarClick={onStarClick}
|
|
/>
|
|
))}
|
|
</group>
|
|
);
|
|
}
|