cosmo/frontend/src/components/Nebulae.tsx

252 lines
8.7 KiB
TypeScript

/**
* Nebulae component - renders nebulae as billboards with procedural textures
*/
import { useEffect, useState, useMemo } from 'react';
import { Billboard, Text } from '@react-three/drei';
import * as THREE from 'three';
import { fetchStaticData } from '../utils/api';
interface Nebula {
name: string;
name_zh: string;
type: string; // emission, planetary, supernova_remnant, dark
distance_ly: number; // Distance in light years
ra: number; // Right Ascension in degrees
dec: number; // Declination in degrees
magnitude: number;
diameter_ly: number; // Diameter in light years
color: string;
}
/**
* Create a procedural nebula texture based on type
*/
function createNebulaTexture(color: string, type: string): THREE.Texture {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d')!;
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const radius = canvas.width / 2;
// Parse color
const tempColor = new THREE.Color(color);
const r = Math.floor(tempColor.r * 255);
const g = Math.floor(tempColor.g * 255);
const b = Math.floor(tempColor.b * 255);
if (type === 'emission') {
// Emission nebulae: bright, colorful, wispy
const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius);
gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.9)`);
gradient.addColorStop(0.3, `rgba(${r}, ${g}, ${b}, 0.7)`);
gradient.addColorStop(0.6, `rgba(${r}, ${g}, ${b}, 0.4)`);
gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Add wispy clouds
for (let i = 0; i < 30; i++) {
const angle = Math.random() * Math.PI * 2;
const dist = Math.random() * radius * 0.8;
const x = centerX + Math.cos(angle) * dist;
const y = centerY + Math.sin(angle) * dist;
const cloudGradient = ctx.createRadialGradient(x, y, 0, x, y, 15);
cloudGradient.addColorStop(0, `rgba(${r + 50}, ${g + 50}, ${b + 50}, 0.3)`);
cloudGradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
ctx.fillStyle = cloudGradient;
ctx.fillRect(x - 15, y - 15, 30, 30);
}
} else if (type === 'planetary') {
// Planetary nebulae: ring-like or spherical structure
const innerRadius = radius * 0.3;
const outerRadius = radius * 0.9;
// Draw ring
for (let r_val = innerRadius; r_val < outerRadius; r_val += 1) {
const alpha = 1 - ((r_val - innerRadius) / (outerRadius - innerRadius));
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${alpha * 0.6})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(centerX, centerY, r_val, 0, Math.PI * 2);
ctx.stroke();
}
// Add central star
const starGradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, 10);
starGradient.addColorStop(0, `rgba(255, 255, 255, 1.0)`);
starGradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0.5)`);
ctx.fillStyle = starGradient;
ctx.beginPath();
ctx.arc(centerX, centerY, 10, 0, Math.PI * 2);
ctx.fill();
} else if (type === 'supernova_remnant') {
// Supernova remnants: filamentary, expanding shell
const gradient = ctx.createRadialGradient(centerX, centerY, radius * 0.3, centerX, centerY, radius);
gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0)`);
gradient.addColorStop(0.5, `rgba(${r}, ${g}, ${b}, 0.8)`);
gradient.addColorStop(0.8, `rgba(${r}, ${g}, ${b}, 0.4)`);
gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Add filaments
for (let i = 0; i < 50; i++) {
const angle = Math.random() * Math.PI * 2;
const dist = radius * 0.5 + Math.random() * radius * 0.4;
const x = centerX + Math.cos(angle) * dist;
const y = centerY + Math.sin(angle) * dist;
const length = Math.random() * 20 + 5;
ctx.strokeStyle = `rgba(${r + 50}, ${g + 50}, ${b + 50}, ${Math.random() * 0.6})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + Math.cos(angle) * length, y + Math.sin(angle) * length);
ctx.stroke();
}
} else if (type === 'dark') {
// Dark nebulae: darker silhouette against background
const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius);
gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.6)`);
gradient.addColorStop(0.5, `rgba(${r}, ${g}, ${b}, 0.4)`);
gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Add darker patches
for (let i = 0; i < 15; i++) {
const x = centerX + (Math.random() - 0.5) * radius;
const y = centerY + (Math.random() - 0.5) * radius;
const patchGradient = ctx.createRadialGradient(x, y, 0, x, y, 20);
patchGradient.addColorStop(0, `rgba(${r * 0.5}, ${g * 0.5}, ${b * 0.5}, 0.5)`);
patchGradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
ctx.fillStyle = patchGradient;
ctx.fillRect(x - 20, y - 20, 40, 40);
}
}
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
}
/**
* Convert RA/Dec to Cartesian coordinates for celestial sphere
*/
function raDecToCartesian(ra: number, dec: number, distance: number) {
const raRad = (ra * Math.PI) / 180;
const decRad = (dec * Math.PI) / 180;
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);
}
/**
* Calculate visual size based on angular diameter
* Nebulae are measured in light years of diameter and distance
*/
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));
}
export function Nebulae() {
const [nebulae, setNebulae] = useState<Nebula[]>([]);
useEffect(() => {
// Load nebula data from API
fetchStaticData('nebula')
.then((response) => {
// Convert API response to Nebula format
const nebulaData = response.items.map((item) => ({
name: item.name,
name_zh: item.name_zh,
type: item.data.type,
distance_ly: item.data.distance_ly,
ra: item.data.ra,
dec: item.data.dec,
magnitude: item.data.magnitude,
diameter_ly: item.data.diameter_ly,
color: item.data.color,
}));
setNebulae(nebulaData);
})
.catch((err) => console.error('Failed to load nebulae:', err));
}, []);
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 position = raDecToCartesian(nebula.ra, nebula.dec, visualDistance);
// Calculate visual size based on angular diameter
const size = calculateAngularSize(nebula.diameter_ly, nebula.distance_ly);
// Create procedural texture for this nebula
const texture = createNebulaTexture(nebula.color, nebula.type);
return {
...nebula,
position,
size,
texture,
};
});
}, [nebulae]);
if (nebulaData.length === 0) {
return null;
}
return (
<group>
{nebulaData.map((nebula) => (
<group key={nebula.name}>
<Billboard
position={nebula.position}
follow={true}
lockX={false}
lockY={false}
lockZ={false}
>
{/* Nebula texture */}
<mesh>
<planeGeometry args={[nebula.size * 2, nebula.size * 2]} />
<meshBasicMaterial
map={nebula.texture}
transparent
opacity={nebula.type === 'dark' ? 0.5 : 0.7}
blending={nebula.type === 'dark' ? THREE.NormalBlending : THREE.AdditiveBlending}
depthWrite={false}
/>
</mesh>
</Billboard>
{/* Nebula name label - positioned slightly outward */}
<Billboard position={nebula.position.clone().multiplyScalar(1.02)}>
<Text
fontSize={1.2}
color="#FFAADD"
anchorX="center"
anchorY="middle"
outlineWidth={0.1}
outlineColor="#000000"
>
{nebula.name_zh}
</Text>
</Billboard>
</group>
))}
</group>
);
}