A ton of AI assisted web development
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/orbital-icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Orbital Simulator</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4470
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "orbital-simulator-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^9.122.0",
|
||||
"@react-three/fiber": "^8.18.0",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"axios": "^1.6.2",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"recharts": "^2.8.0",
|
||||
"three": "^0.160.1",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@types/three": "^0.160.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@vitejs/plugin-react": "^4.1.1",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
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;
|
||||
@@ -0,0 +1,288 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Config, Body } from '../App';
|
||||
|
||||
interface Props {
|
||||
onCreateSimulation: (config: Config, stepSize: number, totalSteps: number) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const ConfigurationPanel: React.FC<Props> = ({ onCreateSimulation, isLoading }) => {
|
||||
const [selectedConfig, setSelectedConfig] = useState('planets.toml');
|
||||
const [stepSize, setStepSize] = useState(3600);
|
||||
const [simulationTime, setSimulationTime] = useState(365);
|
||||
const [customConfig, setCustomConfig] = useState<Config | null>(null);
|
||||
const [showCustomEditor, setShowCustomEditor] = useState(false);
|
||||
|
||||
// Predefined configurations
|
||||
const presetConfigs: Record<string, Config> = {
|
||||
'earthsun_corrected.toml': {
|
||||
bodies: [
|
||||
{
|
||||
name: 'Sun',
|
||||
mass: 1.989e30,
|
||||
position: [0.0, 0.0, 0.0],
|
||||
velocity: [0.0, 0.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Earth',
|
||||
mass: 5.972e24,
|
||||
position: [147095000000.0, 0.0, 0.0],
|
||||
velocity: [0.0, 30290.0, 0.0],
|
||||
},
|
||||
],
|
||||
},
|
||||
'inner_solar_system.toml': {
|
||||
bodies: [
|
||||
{
|
||||
name: 'Sun',
|
||||
mass: 1.989e30,
|
||||
position: [0.0, 0.0, 0.0],
|
||||
velocity: [0.0, 0.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Mercury',
|
||||
mass: 3.30104e23,
|
||||
position: [4.6000e10, 0.0, 0.0],
|
||||
velocity: [0.0, 58970.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Venus',
|
||||
mass: 4.8675e24,
|
||||
position: [1.08939e11, 0.0, 0.0],
|
||||
velocity: [0.0, 34780.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Earth',
|
||||
mass: 5.972e24,
|
||||
position: [1.496e11, 0.0, 0.0],
|
||||
velocity: [0.0, 29789.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Moon',
|
||||
mass: 7.34767309e22,
|
||||
position: [1.49984e11, 0.0, 0.0],
|
||||
velocity: [0.0, 30813.0, 0.0],
|
||||
},
|
||||
],
|
||||
},
|
||||
'solar_system.toml': {
|
||||
bodies: [
|
||||
{
|
||||
name: 'Sun',
|
||||
mass: 1.989e30,
|
||||
position: [0.0, 0.0, 0.0],
|
||||
velocity: [0.0, 0.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Mercury',
|
||||
mass: 3.30104e23,
|
||||
position: [4.6000e10, 0.0, 0.0],
|
||||
velocity: [0.0, 58970.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Venus',
|
||||
mass: 4.8675e24,
|
||||
position: [1.08939e11, 0.0, 0.0],
|
||||
velocity: [0.0, 34780.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Earth',
|
||||
mass: 5.972e24,
|
||||
position: [1.496e11, 0.0, 0.0],
|
||||
velocity: [0.0, 29789.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Mars',
|
||||
mass: 6.4171e23,
|
||||
position: [2.279e11, 0.0, 0.0],
|
||||
velocity: [0.0, 24007.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Jupiter',
|
||||
mass: 1.8982e27,
|
||||
position: [7.785e11, 0.0, 0.0],
|
||||
velocity: [0.0, 13070.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Saturn',
|
||||
mass: 5.6834e26,
|
||||
position: [1.432e12, 0.0, 0.0],
|
||||
velocity: [0.0, 9680.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Uranus',
|
||||
mass: 8.6810e25,
|
||||
position: [2.867e12, 0.0, 0.0],
|
||||
velocity: [0.0, 6810.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Neptune',
|
||||
mass: 1.0241e26,
|
||||
position: [4.515e12, 0.0, 0.0],
|
||||
velocity: [0.0, 5430.0, 0.0],
|
||||
},
|
||||
],
|
||||
},
|
||||
'planets.toml': {
|
||||
bodies: [
|
||||
{
|
||||
name: 'Sun',
|
||||
mass: 1.989e30,
|
||||
position: [0.0, 0.0, 0.0],
|
||||
velocity: [0.0, 0.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Mercury',
|
||||
mass: 3.30104e23,
|
||||
position: [4.6000e10, 0.0, 0.0],
|
||||
velocity: [0.0, 58970.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Venus',
|
||||
mass: 4.867e24,
|
||||
position: [108941000000.0, 0.0, 0.0],
|
||||
velocity: [0.0, 34780.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Earth',
|
||||
mass: 5.972e24,
|
||||
position: [147095000000.0, 0.0, 0.0],
|
||||
velocity: [0.0, 30290.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Moon',
|
||||
mass: 7.34767309e22,
|
||||
position: [147458300000, 0.0, 0.0],
|
||||
velocity: [0.0, 31000.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Mars',
|
||||
mass: 6.4171e23,
|
||||
position: [206620000000.0, 0.0, 0.0],
|
||||
velocity: [0.0, 26500.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Jupiter',
|
||||
mass: 1.8982e27,
|
||||
position: [740520000000.0, 0.0, 0.0],
|
||||
velocity: [0.0, 13720.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Saturn',
|
||||
mass: 5.6834e26,
|
||||
position: [1352550000000.0, 0.0, 0.0],
|
||||
velocity: [0.0, 10180.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Uranus',
|
||||
mass: 8.6810e25,
|
||||
position: [2741300000000.0, 0.0, 0.0],
|
||||
velocity: [0.0, 7110.0, 0.0],
|
||||
},
|
||||
{
|
||||
name: 'Neptune',
|
||||
mass: 1.0241e26,
|
||||
position: [4444450000000.0, 0.0, 0.0],
|
||||
velocity: [0.0, 5500.0, 0.0],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const handleCreateSimulation = () => {
|
||||
const config = customConfig || presetConfigs[selectedConfig];
|
||||
if (!config) return;
|
||||
|
||||
const totalSteps = Math.floor((simulationTime * 24 * 3600) / stepSize);
|
||||
onCreateSimulation(config, stepSize, totalSteps);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>New Simulation</h3>
|
||||
|
||||
<div className="input-group">
|
||||
<label>Configuration</label>
|
||||
<select
|
||||
value={selectedConfig}
|
||||
onChange={(e) => setSelectedConfig(e.target.value)}
|
||||
disabled={showCustomEditor}
|
||||
>
|
||||
<option value="earthsun_corrected.toml">Earth-Sun System</option>
|
||||
<option value="inner_solar_system.toml">Inner Solar System</option>
|
||||
<option value="solar_system.toml">Solar System</option>
|
||||
<option value="planets.toml">Complete System</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>Step Size (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={stepSize}
|
||||
onChange={(e) => setStepSize(Number(e.target.value))}
|
||||
min={1}
|
||||
max={86400}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>Simulation Time (days)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={simulationTime}
|
||||
onChange={(e) => setSimulationTime(Number(e.target.value))}
|
||||
min={1}
|
||||
max={10000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="button"
|
||||
onClick={handleCreateSimulation}
|
||||
disabled={isLoading}
|
||||
style={{ width: '100%', marginBottom: '1rem' }}
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Simulation'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => setShowCustomEditor(!showCustomEditor)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{showCustomEditor ? 'Use Preset' : 'Custom Config'}
|
||||
</button>
|
||||
|
||||
{showCustomEditor && (
|
||||
<div className="input-group">
|
||||
<label>Custom Configuration (JSON)</label>
|
||||
<textarea
|
||||
rows={10}
|
||||
placeholder={JSON.stringify(presetConfigs['earthsun_corrected.toml'], null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const config = JSON.parse(e.target.value);
|
||||
setCustomConfig(config);
|
||||
} catch {
|
||||
setCustomConfig(null);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: '#333',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '4px',
|
||||
color: 'white',
|
||||
padding: '0.5rem',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigurationPanel;
|
||||
@@ -0,0 +1,299 @@
|
||||
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;
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { SimulationUpdate } from '../App';
|
||||
|
||||
interface Props {
|
||||
data: SimulationUpdate;
|
||||
}
|
||||
|
||||
const SimulationControls: React.FC<Props> = ({ data }) => {
|
||||
const formatNumber = (num: number) => {
|
||||
if (num > 1e9) return `${(num / 1e9).toFixed(2)}B`;
|
||||
if (num > 1e6) return `${(num / 1e6).toFixed(2)}M`;
|
||||
if (num > 1e3) return `${(num / 1e3).toFixed(2)}K`;
|
||||
return num.toFixed(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="controls-panel">
|
||||
<div className="view-controls">
|
||||
<h4>Bodies</h4>
|
||||
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
|
||||
{data.bodies.map((body) => (
|
||||
<div key={body.name} className="stat-item" style={{ marginBottom: '0.25rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.9rem' }}>{body.name}</span>
|
||||
<span style={{ fontSize: '0.8rem', color: '#999' }}>
|
||||
{formatNumber(Math.sqrt(
|
||||
body.position[0] ** 2 + body.position[1] ** 2 + body.position[2] ** 2
|
||||
) / 1.496e11)} AU
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.energy && (
|
||||
<div className="view-controls">
|
||||
<h4>Energy</h4>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-item">
|
||||
<div className="stat-label">Kinetic</div>
|
||||
<div className="stat-value">{data.energy.kinetic.toExponential(2)}</div>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<div className="stat-label">Potential</div>
|
||||
<div className="stat-value">{data.energy.potential.toExponential(2)}</div>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<div className="stat-label">Total</div>
|
||||
<div className="stat-value">{data.energy.total.toExponential(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimulationControls;
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import { SimulationInfo, SimulationUpdate } from '../App';
|
||||
import { Play, Pause, Square, Trash2 } from 'lucide-react';
|
||||
import TimelineSlider from './TimelineSlider';
|
||||
|
||||
interface Props {
|
||||
simulations: SimulationInfo[];
|
||||
selectedSimulation: string | null;
|
||||
currentData: SimulationUpdate | null;
|
||||
isAutoPlaying: boolean;
|
||||
onSelectSimulation: (id: string) => void;
|
||||
onDeleteSimulation: (id: string) => void;
|
||||
onControlSimulation: (id: string, action: string) => void;
|
||||
onSeek: (step: number) => void;
|
||||
onToggleAutoPlay: () => void;
|
||||
onRestart: () => void;
|
||||
}
|
||||
|
||||
const SimulationList: React.FC<Props> = ({
|
||||
simulations,
|
||||
selectedSimulation,
|
||||
currentData,
|
||||
isAutoPlaying,
|
||||
onSelectSimulation,
|
||||
onDeleteSimulation,
|
||||
onControlSimulation,
|
||||
onSeek,
|
||||
onToggleAutoPlay,
|
||||
onRestart,
|
||||
}) => {
|
||||
const formatTime = (seconds: number) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="simulation-list-container">
|
||||
<h3>Active Simulations</h3>
|
||||
|
||||
{simulations.length === 0 ? (
|
||||
<div className="loading">No simulations running</div>
|
||||
) : (
|
||||
<div className="simulations-scroll">
|
||||
{simulations.map((sim) => (
|
||||
<div key={sim.id} className="simulation-item">
|
||||
<div
|
||||
className={`simulation-card ${selectedSimulation === sim.id ? 'selected' : ''}`}
|
||||
onClick={() => onSelectSimulation(sim.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
border: selectedSimulation === sim.id ? '2px solid #00aaff' : '1px solid #444',
|
||||
}}
|
||||
>
|
||||
<div className="simulation-status">
|
||||
<div className={`status-indicator ${sim.is_running ? 'running' : 'paused'}`} />
|
||||
<span>{sim.is_running ? 'Running' : 'Paused'}</span>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-item">
|
||||
<div className="stat-label">Bodies</div>
|
||||
<div className="stat-value">{sim.bodies_count}</div>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<div className="stat-label">Step</div>
|
||||
<div className="stat-value">{sim.current_step.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<div className="stat-label">Runtime</div>
|
||||
<div className="stat-value">{formatTime(sim.elapsed_time)}</div>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<div className="stat-label">ID</div>
|
||||
<div className="stat-value" style={{ fontSize: '0.7rem' }}>
|
||||
{sim.id.substring(0, 8)}...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||
<button
|
||||
className="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onControlSimulation(sim.id, sim.is_running ? 'pause' : 'start');
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{sim.is_running ? <Pause size={14} /> : <Play size={14} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onControlSimulation(sim.id, 'stop');
|
||||
}}
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="button danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (confirm('Delete this simulation?')) {
|
||||
onDeleteSimulation(sim.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline slider slides out from selected simulation */}
|
||||
{selectedSimulation === sim.id && currentData && (
|
||||
<div className="timeline-slideout">
|
||||
<TimelineSlider
|
||||
simulation={sim}
|
||||
isAutoPlaying={isAutoPlaying}
|
||||
onSeek={onSeek}
|
||||
onToggleAutoPlay={onToggleAutoPlay}
|
||||
onRestart={onRestart}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimulationList;
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Play, Pause, RotateCcw } from 'lucide-react';
|
||||
import { SimulationInfo } from '../App';
|
||||
|
||||
interface Props {
|
||||
simulation: SimulationInfo;
|
||||
isAutoPlaying: boolean;
|
||||
onSeek: (step: number) => void;
|
||||
onToggleAutoPlay: () => void;
|
||||
onRestart: () => void;
|
||||
}
|
||||
|
||||
const TimelineSlider: React.FC<Props> = ({
|
||||
simulation,
|
||||
isAutoPlaying,
|
||||
onSeek,
|
||||
onToggleAutoPlay,
|
||||
onRestart
|
||||
}) => {
|
||||
const [localStep, setLocalStep] = useState(simulation.playback_step);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Update local step when simulation updates (but not while dragging)
|
||||
useEffect(() => {
|
||||
if (!isDragging) {
|
||||
setLocalStep(simulation.playback_step);
|
||||
}
|
||||
}, [simulation.playback_step, isDragging]);
|
||||
|
||||
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const step = parseInt(e.target.value);
|
||||
setLocalStep(step);
|
||||
// Immediately seek to the new step for real-time updates
|
||||
onSeek(step);
|
||||
};
|
||||
|
||||
const handleSliderMouseDown = () => {
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleSliderMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
// Final seek to ensure we're at the exact position
|
||||
onSeek(localStep);
|
||||
};
|
||||
|
||||
const handleSliderKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
onSeek(localStep);
|
||||
}
|
||||
};
|
||||
|
||||
const formatStep = (step: number) => {
|
||||
if (step > 1e6) return `${(step / 1e6).toFixed(1)}M`;
|
||||
if (step > 1e3) return `${(step / 1e3).toFixed(1)}K`;
|
||||
return step.toString();
|
||||
};
|
||||
|
||||
const progressPercent = simulation.recorded_steps > 0
|
||||
? (simulation.current_step / Math.max(simulation.recorded_steps, simulation.total_steps)) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="timeline-slider">
|
||||
<div className="timeline-header">
|
||||
<h4>Timeline Control</h4>
|
||||
<div className="timeline-info">
|
||||
<span>Step: {formatStep(localStep)} / {formatStep(simulation.recorded_steps)}</span>
|
||||
<span>Progress: {progressPercent.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="timeline-controls">
|
||||
<button
|
||||
onClick={onRestart}
|
||||
className="control-button"
|
||||
title="Restart from beginning"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onToggleAutoPlay}
|
||||
className={`control-button ${isAutoPlaying ? 'active' : ''}`}
|
||||
title={isAutoPlaying ? 'Pause auto-play' : 'Start auto-play'}
|
||||
>
|
||||
{isAutoPlaying ? <Pause size={16} /> : <Play size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="slider-container">
|
||||
{/* Progress bar showing simulation progress */}
|
||||
<div className="progress-track">
|
||||
<div
|
||||
className="progress-bar"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main timeline slider */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={Math.max(simulation.recorded_steps - 1, 0)}
|
||||
value={localStep}
|
||||
onChange={handleSliderChange}
|
||||
onMouseDown={handleSliderMouseDown}
|
||||
onMouseUp={handleSliderMouseUp}
|
||||
onKeyUp={handleSliderKeyUp}
|
||||
className="timeline-range"
|
||||
disabled={simulation.recorded_steps === 0}
|
||||
/>
|
||||
|
||||
{/* Step markers */}
|
||||
<div className="step-markers">
|
||||
<span className="step-marker start">0</span>
|
||||
<span className="step-marker current">
|
||||
{formatStep(localStep)}
|
||||
</span>
|
||||
<span className="step-marker end">
|
||||
{formatStep(Math.max(simulation.total_steps, simulation.recorded_steps))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="timeline-status">
|
||||
<div className="status-item">
|
||||
<span className="status-label">Simulation:</span>
|
||||
<span className={`status-value ${simulation.is_running ? 'running' : 'paused'}`}>
|
||||
{simulation.is_running ? 'Running' : 'Paused'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-item">
|
||||
<span className="status-label">Auto-play:</span>
|
||||
<span className={`status-value ${isAutoPlaying ? 'active' : 'inactive'}`}>
|
||||
{isAutoPlaying ? 'On' : 'Off'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineSlider;
|
||||
@@ -0,0 +1,482 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #1a1a1a;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #00aaff;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
background: #1a1a1a;
|
||||
border-right: 1px solid #333;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.simulation-view {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: #00aaff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: #0088cc;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
background: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.button.secondary:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
background: #ff4444;
|
||||
}
|
||||
|
||||
.button.danger:hover {
|
||||
background: #cc0000;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.input-group input,
|
||||
.input-group select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: #333;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.simulation-card {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.simulation-card.selected {
|
||||
transform: translateX(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 170, 255, 0.3);
|
||||
}
|
||||
|
||||
.simulation-card h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #00aaff;
|
||||
}
|
||||
|
||||
.simulation-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
}
|
||||
|
||||
.status-indicator.running {
|
||||
background: #00ff00;
|
||||
}
|
||||
|
||||
.status-indicator.paused {
|
||||
background: #ffaa00;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: #333;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #999;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.controls-panel {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(26, 26, 26, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.view-controls {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.view-controls h4 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #00aaff;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
font-size: 1.2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff4444;
|
||||
background: rgba(255, 68, 68, 0.1);
|
||||
border: 1px solid #ff4444;
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* Timeline Slider Styles */
|
||||
.timeline-slider {
|
||||
background: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.timeline-header h4 {
|
||||
color: #00aaff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.timeline-info {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.timeline-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
background: #333;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.control-button:hover {
|
||||
background: #444;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.control-button.active {
|
||||
background: #00aaff;
|
||||
border-color: #00aaff;
|
||||
}
|
||||
|
||||
.control-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
position: relative;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 4px;
|
||||
background: #333;
|
||||
border-radius: 2px;
|
||||
margin-bottom: 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00aaff, #0088cc);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.timeline-range {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #333;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.timeline-range::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #00aaff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.timeline-range::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #00aaff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.timeline-range:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.step-markers {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.step-marker {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.step-marker.current {
|
||||
color: #00aaff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.timeline-status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-value.running {
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.status-value.paused {
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.status-value.active {
|
||||
color: #00aaff;
|
||||
}
|
||||
|
||||
.status-value.inactive {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Simulation List Enhancements */
|
||||
.simulation-list-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.simulations-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.5rem;
|
||||
margin-right: -0.5rem;
|
||||
}
|
||||
|
||||
.simulations-scroll::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.simulations-scroll::-webkit-scrollbar-track {
|
||||
background: #1a1a1a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.simulations-scroll::-webkit-scrollbar-thumb {
|
||||
background: #444;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.simulations-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.simulation-item {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Timeline Slideout Animation */
|
||||
.timeline-slideout {
|
||||
margin-top: 0.5rem;
|
||||
margin-left: 1rem;
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid #00aaff;
|
||||
background: rgba(0, 170, 255, 0.05);
|
||||
border-radius: 0 8px 8px 0;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
to {
|
||||
max-height: 300px;
|
||||
opacity: 1;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-slideout .timeline-slider {
|
||||
margin-bottom: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.timeline-slideout .timeline-header h4 {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.timeline-slideout .timeline-controls {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.timeline-slideout .control-button {
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.timeline-slideout .slider-container {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.timeline-slideout .timeline-status {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user