252 lines
8.7 KiB
TypeScript
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>
|
|
);
|
|
}
|