A ton of AI assisted web development

This commit is contained in:
Thomas Faour
2025-06-21 23:29:14 -04:00
parent a8fcb5a7d9
commit e59d1d90b3
40 changed files with 8990 additions and 139 deletions
+13
View File
@@ -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>
+4470
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -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
View File
@@ -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;
+288
View File
@@ -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;
+299
View File
@@ -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;
+59
View File
@@ -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;
+138
View File
@@ -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;
+144
View File
@@ -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;
+482
View File
@@ -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;
}
+10
View File
@@ -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>,
)
+21
View File
@@ -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" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+18
View File
@@ -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',
}
})