cosmo/frontend/src/components/Stars.tsx

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