319 lines
8.7 KiB
TypeScript
319 lines
8.7 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import SimulationCanvas from './components/SimulationCanvas';
|
|
import SimulationControls from './components/SimulationControls';
|
|
import SimulationList from './components/SimulationList';
|
|
import ConfigurationPanel from './components/ConfigurationPanel';
|
|
import { Play, Pause, Square } from 'lucide-react';
|
|
|
|
export interface Body {
|
|
name: string;
|
|
mass: number;
|
|
position: [number, number, number];
|
|
velocity: [number, number, number];
|
|
}
|
|
|
|
export interface Config {
|
|
bodies: Body[];
|
|
normalization?: {
|
|
m_0: number;
|
|
r_0: number;
|
|
t_0: number;
|
|
};
|
|
}
|
|
|
|
export interface SimulationInfo {
|
|
id: string;
|
|
is_running: boolean;
|
|
current_step: number;
|
|
playback_step: number;
|
|
total_steps: number;
|
|
recorded_steps: number;
|
|
bodies_count: number;
|
|
elapsed_time: number;
|
|
}
|
|
|
|
export interface BodyState {
|
|
name: string;
|
|
position: [number, number, number];
|
|
velocity: [number, number, number];
|
|
mass: number;
|
|
}
|
|
|
|
export interface SimulationUpdate {
|
|
step: number;
|
|
time: number;
|
|
bodies: BodyState[];
|
|
energy?: {
|
|
kinetic: number;
|
|
potential: number;
|
|
total: number;
|
|
};
|
|
}
|
|
|
|
function App() {
|
|
const [simulations, setSimulations] = useState<SimulationInfo[]>([]);
|
|
const [selectedSimulation, setSelectedSimulation] = useState<string | null>(null);
|
|
const [currentData, setCurrentData] = useState<SimulationUpdate | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isAutoPlaying, setIsAutoPlaying] = useState(true); // Auto-play timeline by default
|
|
const [sessionId] = useState(() => {
|
|
// Generate or retrieve session ID from localStorage
|
|
let id = localStorage.getItem('orbital-sim-session');
|
|
if (!id) {
|
|
id = crypto.randomUUID();
|
|
localStorage.setItem('orbital-sim-session', id);
|
|
}
|
|
return id;
|
|
});
|
|
|
|
// Helper function to add session header to requests
|
|
const getHeaders = () => ({
|
|
'Content-Type': 'application/json',
|
|
'X-Session-ID': sessionId,
|
|
});
|
|
|
|
// Fetch simulations list
|
|
const fetchSimulations = async () => {
|
|
try {
|
|
const response = await fetch('/api/simulations', {
|
|
headers: { 'X-Session-ID': sessionId },
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setSimulations(data);
|
|
}
|
|
} catch (err) {
|
|
setError('Failed to fetch simulations');
|
|
}
|
|
};
|
|
|
|
// Create new simulation
|
|
const createSimulation = async (config: Config, stepSize: number, totalSteps: number) => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await fetch('/api/simulations', {
|
|
method: 'POST',
|
|
headers: getHeaders(),
|
|
body: JSON.stringify({
|
|
config,
|
|
step_size: stepSize,
|
|
total_steps: totalSteps,
|
|
}),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const newSim = await response.json();
|
|
setSelectedSimulation(newSim.id);
|
|
await fetchSimulations();
|
|
} else {
|
|
setError('Failed to create simulation');
|
|
}
|
|
} catch (err) {
|
|
setError('Failed to create simulation');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Control simulation
|
|
const controlSimulation = async (id: string, action: string) => {
|
|
try {
|
|
const response = await fetch(`/api/simulations/${id}/control?action=${action}`, {
|
|
method: 'POST',
|
|
headers: { 'X-Session-ID': sessionId },
|
|
});
|
|
|
|
if (response.ok) {
|
|
await fetchSimulations();
|
|
}
|
|
} catch (err) {
|
|
setError(`Failed to ${action} simulation`);
|
|
}
|
|
};
|
|
|
|
// Delete simulation
|
|
const deleteSimulation = async (id: string) => {
|
|
try {
|
|
const response = await fetch(`/api/simulations/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'X-Session-ID': sessionId },
|
|
});
|
|
|
|
if (response.ok) {
|
|
if (selectedSimulation === id) {
|
|
setSelectedSimulation(null);
|
|
setCurrentData(null);
|
|
}
|
|
await fetchSimulations();
|
|
}
|
|
} catch (err) {
|
|
setError('Failed to delete simulation');
|
|
}
|
|
};
|
|
|
|
// Seek to specific simulation step
|
|
const seekToStep = async (id: string, step: number) => {
|
|
try {
|
|
const response = await fetch(`/api/simulations/${id}/seek?step=${step}`, {
|
|
method: 'POST',
|
|
headers: { 'X-Session-ID': sessionId },
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setCurrentData(data);
|
|
await fetchSimulations(); // Update playback_step in simulation info
|
|
} else {
|
|
setError('Failed to seek simulation');
|
|
}
|
|
} catch (err) {
|
|
setError('Failed to seek simulation');
|
|
}
|
|
};
|
|
|
|
// Timeline control functions
|
|
const handleSeek = (step: number) => {
|
|
if (selectedSimulation) {
|
|
seekToStep(selectedSimulation, step);
|
|
}
|
|
};
|
|
|
|
const handleToggleAutoPlay = () => {
|
|
setIsAutoPlaying(!isAutoPlaying);
|
|
};
|
|
|
|
const handleRestart = () => {
|
|
if (selectedSimulation) {
|
|
seekToStep(selectedSimulation, 0);
|
|
}
|
|
};
|
|
|
|
// Poll simulation data (only when auto-playing or simulation is running)
|
|
useEffect(() => {
|
|
if (!selectedSimulation) return;
|
|
|
|
const interval = setInterval(async () => {
|
|
const selectedSim = simulations.find(s => s.id === selectedSimulation);
|
|
|
|
// Only poll if auto-playing is enabled or if simulation is running
|
|
if (!isAutoPlaying && selectedSim && !selectedSim.is_running) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/simulations/${selectedSimulation}`, {
|
|
headers: { 'X-Session-ID': sessionId },
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setCurrentData(data);
|
|
}
|
|
} catch (err) {
|
|
// Silently fail for polling
|
|
}
|
|
}, 100); // 10 FPS updates
|
|
|
|
return () => clearInterval(interval);
|
|
}, [selectedSimulation, isAutoPlaying, simulations]);
|
|
|
|
// Initial load
|
|
useEffect(() => {
|
|
fetchSimulations();
|
|
}, []);
|
|
|
|
const selectedSim = simulations.find(s => s.id === selectedSimulation);
|
|
|
|
return (
|
|
<div className="app">
|
|
<header className="header">
|
|
<div className="logo">Orbital Simulator</div>
|
|
<div className="controls">
|
|
{selectedSim && (
|
|
<>
|
|
<button
|
|
className="button"
|
|
onClick={() => controlSimulation(selectedSim.id, selectedSim.is_running ? 'pause' : 'start')}
|
|
disabled={isLoading}
|
|
>
|
|
{selectedSim.is_running ? <Pause size={16} /> : <Play size={16} />}
|
|
{selectedSim.is_running ? 'Pause' : 'Start'}
|
|
</button>
|
|
<button
|
|
className="button secondary"
|
|
onClick={() => controlSimulation(selectedSim.id, 'step')}
|
|
disabled={isLoading || selectedSim.is_running}
|
|
>
|
|
Step
|
|
</button>
|
|
<button
|
|
className="button secondary"
|
|
onClick={() => controlSimulation(selectedSim.id, 'stop')}
|
|
disabled={isLoading}
|
|
>
|
|
<Square size={16} />
|
|
Stop
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
<div className="main-content">
|
|
<div className="sidebar">
|
|
<ConfigurationPanel
|
|
onCreateSimulation={createSimulation}
|
|
isLoading={isLoading}
|
|
/>
|
|
|
|
<SimulationList
|
|
simulations={simulations}
|
|
selectedSimulation={selectedSimulation}
|
|
currentData={currentData}
|
|
isAutoPlaying={isAutoPlaying}
|
|
onSelectSimulation={setSelectedSimulation}
|
|
onDeleteSimulation={deleteSimulation}
|
|
onControlSimulation={controlSimulation}
|
|
onSeek={handleSeek}
|
|
onToggleAutoPlay={handleToggleAutoPlay}
|
|
onRestart={handleRestart}
|
|
/>
|
|
|
|
{selectedSim && currentData && (
|
|
<SimulationControls
|
|
data={currentData}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="simulation-view">
|
|
{error && (
|
|
<div className="error">
|
|
{error}
|
|
<button
|
|
className="button secondary"
|
|
onClick={() => setError(null)}
|
|
style={{ marginLeft: '1rem' }}
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{currentData ? (
|
|
<SimulationCanvas data={currentData} />
|
|
) : selectedSimulation ? (
|
|
<div className="loading">Loading simulation data...</div>
|
|
) : (
|
|
<div className="loading">Select or create a simulation to begin</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|