OrbitalSimulator/web/src/components/SimulationCanvas.tsx
2025-06-21 23:29:14 -04:00

300 lines
9.0 KiB
TypeScript

import React, { useRef, useEffect, useState } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls, Text } from '@react-three/drei';
import * as THREE from 'three';
interface Body {
name: string;
position: [number, number, number];
velocity: [number, number, number];
mass: number;
}
interface SimulationUpdate {
step: number;
time: number;
bodies: Body[];
}
interface Props {
data: SimulationUpdate;
resetTrails?: boolean; // Signal to reset all trails
}
// Component for rendering a celestial body
function CelestialBody({ body, index, scaleFactor, resetTrails }: {
body: Body;
index: number;
scaleFactor: number;
resetTrails?: boolean;
}) {
const meshRef = useRef<THREE.Mesh>(null);
const trailRef = useRef<THREE.Line>(null);
const trailPoints = useRef<THREE.Vector3[]>([]);
const lastPosition = useRef<THREE.Vector3 | null>(null);
const lastScaleFactor = useRef<number>(scaleFactor);
// Color palette for different bodies
const colors = [
'#FFD700', // Gold for Sun
'#FFA500', // Orange for Mercury
'#FF6347', // Tomato for Venus
'#4169E1', // Royal Blue for Earth
'#FF0000', // Red for Mars
'#DAA520', // Goldenrod for Jupiter
'#F4A460', // Sandy Brown for Saturn
'#40E0D0', // Turquoise for Uranus
'#0000FF', // Blue for Neptune
'#DDA0DD', // Plum for other bodies
];
const color = colors[index % colors.length];
// Scale positions based on the scale factor
// Default scale factor of 1 means 1 AU, scale factor of 0.1 means 0.1 AU, etc.
const AU = 1.496e11; // 1 AU in meters
const scaledPosition = [
body.position[0] / (AU * scaleFactor),
body.position[1] / (AU * scaleFactor),
body.position[2] / (AU * scaleFactor)
];
// Calculate sphere size with better scaling for visualization
// Make the Sun larger and planets visible
let size;
if (body.name.toLowerCase().includes('sun')) {
size = 0.2; // Sun gets a fixed, visible size
} else {
// Scale planet sizes logarithmically but make them visible
const earthMass = 5.972e24;
const massRatio = body.mass / earthMass;
size = Math.max(0.03, Math.min(Math.pow(massRatio, 1/3) * 0.08, 0.15));
}
// Update trail with AU-scaled positions
useEffect(() => {
const position = new THREE.Vector3(...scaledPosition);
// Check if we need to reset trails
const shouldReset = resetTrails ||
Math.abs(scaleFactor - lastScaleFactor.current) > 0.01 || // Scale factor changed
(lastPosition.current && position.distanceTo(lastPosition.current) > 5); // Big position jump
if (shouldReset) {
// Reset trails for discontinuous changes
trailPoints.current = [position.clone()];
} else {
// Normal trail update
trailPoints.current.push(position.clone());
// Keep trail length manageable
if (trailPoints.current.length > 1000) {
trailPoints.current.shift();
}
}
// Update references for next comparison
lastPosition.current = position.clone();
lastScaleFactor.current = scaleFactor;
// Update trail geometry
if (trailRef.current && trailPoints.current.length > 1) {
const geometry = new THREE.BufferGeometry().setFromPoints(trailPoints.current);
trailRef.current.geometry.dispose();
trailRef.current.geometry = geometry;
}
}, [body.position, scaleFactor, resetTrails]);
return (
<group>
{/* Trail */}
{trailPoints.current.length > 1 && (
<primitive
object={new THREE.Line(
new THREE.BufferGeometry().setFromPoints(trailPoints.current),
new THREE.LineBasicMaterial({ color: color, transparent: true, opacity: 0.5 })
)}
/>
)}
{/* Body */}
<mesh
ref={meshRef}
position={scaledPosition as [number, number, number]}
>
<sphereGeometry args={[size, 16, 16]} />
<meshStandardMaterial
color={color}
emissive={index === 0 ? color : '#000000'} // Sun glows
emissiveIntensity={index === 0 ? 0.3 : 0}
/>
</mesh>
{/* Body label */}
<Text
position={[
scaledPosition[0],
scaledPosition[1] + size + 0.3,
scaledPosition[2],
]}
fontSize={0.1}
color="white"
anchorX="center"
anchorY="middle"
>
{body.name}
</Text>
</group>
);
}
// Component for the 3D scene
function Scene({ data, scaleFactor, resetTrails }: Props & { scaleFactor: number }) {
return (
<>
{/* Lighting */}
<ambientLight intensity={0.3} />
<pointLight position={[0, 0, 0]} intensity={2} color="#FFD700" />
<pointLight position={[100, 100, 100]} intensity={0.5} />
{/* Bodies */}
{data.bodies.map((body, index) => (
<CelestialBody
key={body.name}
body={body}
index={index}
scaleFactor={scaleFactor}
resetTrails={resetTrails}
/>
))}
{/* Grid removed for cleaner space view */}
{/* Controls */}
<OrbitControls
enablePan={true}
enableZoom={true}
enableRotate={true}
minDistance={0.5}
maxDistance={50}
/>
</>
);
}
const SimulationCanvas: React.FC<Props> = ({ data, resetTrails }) => {
const [scaleFactor, setScaleFactor] = useState(1.0); // 1.0 = 1 AU
const [internalResetTrails, setInternalResetTrails] = useState(false);
const lastStep = useRef<number>(data.step);
const lastScaleFactor = useRef<number>(1.0);
// Detect big timeline jumps
useEffect(() => {
const stepDifference = Math.abs(data.step - lastStep.current);
const isBigJump = stepDifference > 100; // Consider jumps > 100 steps as "big"
if (isBigJump) {
setInternalResetTrails(true);
// Reset the flag after a short delay to trigger the effect
setTimeout(() => setInternalResetTrails(false), 50);
}
lastStep.current = data.step;
}, [data.step]);
// Handle external reset signals and scale changes
useEffect(() => {
const scaleChanged = Math.abs(scaleFactor - lastScaleFactor.current) > 0.01;
if (resetTrails || scaleChanged) {
setInternalResetTrails(true);
setTimeout(() => setInternalResetTrails(false), 50);
}
lastScaleFactor.current = scaleFactor;
}, [resetTrails, scaleFactor]);
const scaleOptions = [
{ value: 0.1, label: '0.1 AU', description: 'Inner planets' },
{ value: 0.5, label: '0.5 AU', description: 'Close view' },
{ value: 1.0, label: '1 AU', description: 'Default' },
{ value: 2.0, label: '2 AU', description: 'Wide view' },
{ value: 5.0, label: '5 AU', description: 'Outer system' },
{ value: 10.0, label: '10 AU', description: 'Very wide' },
];
const getCurrentScaleLabel = () => {
const current = scaleOptions.find(opt => opt.value === scaleFactor);
return current ? current.label : `${scaleFactor} AU`;
};
return (
<div style={{ width: '100%', height: '100%' }}>
<Canvas
camera={{
position: [5, 5, 5],
fov: 60,
}}
style={{ background: '#000' }}
>
<Scene data={data} scaleFactor={scaleFactor} resetTrails={resetTrails || internalResetTrails} />
</Canvas>
{/* Scale control slider */}
<div style={{
position: 'absolute',
top: '1rem',
right: '1rem',
background: 'rgba(0, 0, 0, 0.7)',
padding: '1rem',
borderRadius: '8px',
color: 'white',
fontFamily: 'monospace',
minWidth: '200px',
}}>
<h4 style={{ margin: '0 0 0.5rem 0', fontSize: '0.9rem' }}>View Scale</h4>
<div style={{ marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.8rem', color: '#ccc' }}>Scale: {getCurrentScaleLabel()}</span>
</div>
<input
type="range"
min="0.1"
max="10"
step="0.1"
value={scaleFactor}
onChange={(e) => setScaleFactor(parseFloat(e.target.value))}
style={{
width: '100%',
marginBottom: '0.5rem',
}}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.7rem', color: '#999' }}>
<span>Close</span>
<span>Far</span>
</div>
</div>
{/* Overlay information */}
<div style={{
position: 'absolute',
top: '1rem',
left: '1rem',
background: 'rgba(0, 0, 0, 0.7)',
padding: '1rem',
borderRadius: '8px',
color: 'white',
fontFamily: 'monospace',
}}>
<div>Step: {data.step.toLocaleString()}</div>
<div>Time: {(data.time / 86400).toFixed(2)} days</div>
<div>Bodies: {data.bodies.length}</div>
<div style={{ fontSize: '0.9rem', marginTop: '0.5rem', color: '#ccc' }}>
Scale: 1 unit = {scaleFactor} AU ({(scaleFactor * 150).toFixed(0)}M km)
</div>
</div>
</div>
);
};
export default SimulationCanvas;