300 lines
9.0 KiB
TypeScript
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;
|