main
mula.liu 2026-07-21 20:10:32 +08:00
parent d93b4f787a
commit 5433892bf6
21 changed files with 0 additions and 6653 deletions

View File

@ -1,9 +0,0 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"

View File

@ -1,8 +0,0 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

View File

@ -1,20 +0,0 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/60f28484-41b5-453e-beeb-3d312fb598db
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -1,6 +0,0 @@
{
"name": "",
"description": "",
"requestFramePermissions": [],
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
}

File diff suppressed because it is too large Load Diff

View File

@ -1,38 +0,0 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist server.js",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^2.4.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"clsx": "^2.1.1",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"recharts": "^3.8.1",
"tailwind-merge": "^3.6.0",
"vite": "^6.2.3"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3"
}
}

View File

@ -1,118 +0,0 @@
import { CONSTANTS, PRESETS } from "./src/types";
let h = 0, x = 0, vx = 0, vy = 0, v = 0, pitch = 90, t = 0;
let currentStage = 0;
let fuel = PRESETS[0].stages[0].fuelMass;
const currentEvents = [];
let maxQ = 0;
let isRunning = true;
for (let i = 0; i < 50000; i++) {
const dt = 0.1;
const rocket = PRESETS[0];
const { area, cd, stages } = rocket;
const stage = stages[currentStage];
const { dryMass, maxThrust, isp } = stage;
let upperStagesMass = 0;
for (let j = currentStage + 1; j < stages.length; j++) {
upperStagesMass += stages[j].dryMass + stages[j].fuelMass;
}
const m = dryMass + fuel + upperStagesMass;
const mDotMax = maxThrust / (isp * CONSTANTS.g0);
let actualThrottle = 1;
if (fuel <= 0 && currentStage < stages.length - 1) {
currentStage++;
fuel = stages[currentStage].fuelMass;
if (!currentEvents.find((e) => e.name === `stageSep${currentStage}`)) {
currentEvents.push({ name: `stageSep${currentStage}`, time: t });
}
} else if (fuel <= 0) {
actualThrottle = 0;
fuel = 0;
}
const currentThrust = maxThrust * actualThrottle;
const mDot = mDotMax * actualThrottle;
if (actualThrottle > 0) {
fuel -= mDot * dt;
if (fuel < 0) fuel = 0;
}
v = Math.sqrt(vx * vx + vy * vy);
const pitching = h > 500;
if (pitching) {
pitch = 90 * Math.exp(-h / 30000);
}
if (h <= 500) {
pitch = 90;
}
const pitchRad = (pitch * Math.PI) / 180;
const fg = (CONSTANTS.G * (CONSTANTS.M_EARTH * m)) / Math.pow(CONSTANTS.R_EARTH + h, 2);
const centrifugalY = (m * vx * vx) / (CONSTANTS.R_EARTH + h);
const rho = h > 100000 ? 0 : CONSTANTS.RHO_0 * Math.exp(-h / CONSTANTS.H);
const currentQ = 0.5 * rho * v * v;
const fd = currentQ * cd * area;
const thrustX = currentThrust * Math.cos(pitchRad);
const thrustY = currentThrust * Math.sin(pitchRad);
const vDirX = v > 0 ? vx / v : 0;
const vDirY = v > 0 ? vy / v : 1;
const dragX = fd * vDirX;
const dragY = fd * vDirY;
const netForceX = thrustX - dragX;
const netForceY = thrustY - dragY - fg + centrifugalY;
let ax = netForceX / m;
let ay = netForceY / m;
if (h <= 0 && vy <= 0 && netForceY <= 0) {
ay = 0; ax = 0; vx = 0; vy = 0; h = 0; pitch = 90;
}
vx += ax * dt;
vy += ay * dt;
x += vx * dt;
h += vy * dt;
if (h < 0) h = 0;
t += dt;
v = Math.sqrt(vx * vx + vy * vy);
if (h > 10 && v > 1 && !currentEvents.find((e) => e.name === "liftoff")) {
currentEvents.push({ name: "liftoff", time: t });
}
if (currentQ > maxQ) maxQ = currentQ;
if (maxQ > 20000 && currentQ < maxQ * 0.95 && !currentEvents.find((e) => e.name === "maxq")) {
currentEvents.push({ name: "maxq", time: t });
}
if (currentStage === stages.length - 1 && fuel <= 0 && t > 10 && !currentEvents.find((e) => e.name === "meco")) {
currentEvents.push({ name: "meco", time: t });
}
if (h >= 100000 && !currentEvents.find((e) => e.name === "karman")) {
currentEvents.push({ name: "karman", time: t });
}
if (vx >= 7800 && !currentEvents.find((e) => e.name === "orbit")) {
currentEvents.push({ name: "orbit", time: t });
}
if (i % 500 === 0) {
console.log(`t: ${t.toFixed(1)}, stage: ${currentStage}, h: ${(h/1000).toFixed(1)}km, v: ${v.toFixed(1)}m/s, vx: ${vx.toFixed(1)}, pitch: ${pitch.toFixed(1)}, fuel: ${fuel.toFixed(0)}, FnetY: ${netForceY.toFixed(0)}`);
}
if (currentEvents.find((e) => e.name === "meco") && t > currentEvents.find((e) => e.name === "meco").time + 3) {
console.log("SIM ENDED");
break;
}
}
console.log(currentEvents);

View File

@ -1,466 +0,0 @@
import { useState, useRef, useEffect } from "react";
import {
Rocket,
Play,
Pause,
RotateCcw,
Activity,
Globe,
Camera,
Volume2,
VolumeX,
Music,
} from "lucide-react";
import { useSimulation } from "./useSimulation";
import { PRESETS } from "./types";
import { DashboardChart } from "./components/DashboardChart";
import { FlightVisualizer } from "./components/FlightVisualizer";
import { TelemetryGauge } from "./components/TelemetryGauge";
import { Navball } from "./components/Navball";
import { i18n, Lang } from "./i18n";
export default function App() {
const [lang, setLang] = useState<Lang>("en");
const [cameraMode, setCameraMode] = useState<"dynamic" | "follow">("dynamic");
const [audioOn, setAudioOn] = useState(false);
const [bgmUrl, setBgmUrl] = useState<string>("");
const { rocket, state, setThrottle, changePreset, toggleSimulation, reset } =
useSimulation(PRESETS[0]);
const t = i18n[lang];
const formatNumber = (num: number, decimals: number = 1) =>
num.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
let totalDryMass = 0;
let totalFuelMass = 0;
let maxPossibleThrust = 0;
rocket.stages.forEach((s) => {
totalDryMass += s.dryMass;
totalFuelMass += s.fuelMass;
if (s.maxThrust > maxPossibleThrust) maxPossibleThrust = s.maxThrust;
});
const totalMass = totalDryMass + state.fuel; // Simplified for TWR calc
const currentThrust =
rocket.stages[state.currentStage].maxThrust * state.throttle;
const tWR = currentThrust / (totalMass * 9.80665);
const drag = state.q * rocket.cd * rocket.area;
let tempC = 15 - 0.0065 * Math.min(state.altitude, 11000);
if (state.altitude > 11000) tempC = -56.5;
const speedOfSound = Math.sqrt(1.4 * 287 * (tempC + 273.15));
const mach = state.velocity / speedOfSound;
const gForce =
state.altitude <= 0 && currentThrust < totalMass * 9.80665
? 1
: Math.max(0, currentThrust - drag) / (totalMass * 9.80665);
const formatTime = (totalSeconds: number) => {
const mins = Math.floor(totalSeconds / 60);
const secs = Math.floor(totalSeconds % 60);
return `T+ ${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
};
const isFinished = state.events.some((e) => e.name === "simEnded");
return (
<div className="relative w-screen h-screen bg-black overflow-hidden text-slate-100 selection:bg-white/20">
{bgmUrl && <audio autoPlay loop muted={!audioOn} src={bgmUrl} />}
{/* Absolute Background Visualizer */}
<FlightVisualizer rocket={rocket} state={state} cameraMode={cameraMode} />
{/* Top Left: Logo / Mission Info Overlay */}
<div className="absolute top-6 left-8 z-50 pointer-events-none drop-shadow-lg">
<div className="flex items-center gap-3">
<div className="p-2 bg-white/10 backdrop-blur-md rounded-xl border border-white/20">
<Rocket className="w-6 h-6 text-white" />
</div>
<div className="flex flex-col">
<h1 className="text-2xl font-sans font-black tracking-tighter text-white uppercase leading-none">
{t.appTitle}
</h1>
<p className="text-[10px] text-white/70 font-mono tracking-[0.2em] uppercase mt-1">
{t.appSub}
</p>
</div>
</div>
</div>
{/* Top Right: Preset Selector & Lang Toggle */}
<div className="absolute top-6 right-8 z-50 flex gap-4">
<div className="flex bg-black/40 backdrop-blur-md border border-white/10 rounded-lg p-1.5 items-center">
<select
value={rocket.id}
onChange={(e) => {
const p = PRESETS.find((x) => x.id === e.target.value);
if (p) changePreset(p);
}}
className="bg-transparent text-white/90 text-xs font-bold uppercase tracking-widest outline-none px-3 py-1 cursor-pointer appearance-none"
>
{PRESETS.map((p) => (
<option
key={p.id}
value={p.id}
className="bg-slate-900 text-white"
>
{p.name}
</option>
))}
</select>
</div>
<button
onClick={() => {
setAudioOn((v) => !v);
}}
className="p-2 bg-black/30 backdrop-blur-md border border-white/10 rounded-lg text-white/70 hover:bg-white/10 hover:text-white transition-all shadow-lg flex items-center justify-center"
title="Toggle Global Audio (Mutes/Unmutes BGM)"
>
{audioOn ? (
<Volume2 className="w-5 h-5" />
) : (
<VolumeX className="w-5 h-5" />
)}
</button>
<button
onClick={() => setLang((l) => (l === "en" ? "zh" : "en"))}
className="p-2 bg-black/30 backdrop-blur-md border border-white/10 rounded-lg text-white/70 hover:bg-white/10 hover:text-white transition-all shadow-lg flex items-center justify-center"
title="Toggle Language"
>
<Globe className="w-5 h-5" />
</button>
</div>
{/* Control Panel (Floating Glass Panel Left) */}
<div className="absolute left-8 top-1/2 -translate-y-1/2 w-80 bg-black/40 backdrop-blur-xl border border-white/10 rounded-[2rem] p-6 z-50 shadow-2xl">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-2 text-white/80">
<Activity className="w-4 h-4" />
<h2 className="text-xs font-bold tracking-widest uppercase">
{t.directives}
</h2>
</div>
<button
onClick={() =>
setCameraMode((m) => (m === "dynamic" ? "follow" : "dynamic"))
}
className="px-2 py-1 bg-white/5 border border-white/10 rounded-md text-white/60 hover:bg-white/10 hover:text-white transition-all flex items-center justify-center gap-1.5"
title="Toggle Camera Mode"
>
<Camera className="w-3 h-3" />
<span className="text-[8px] font-bold tracking-widest uppercase">
{cameraMode === "dynamic" ? t.cameraDynamic : t.cameraFollow}
</span>
</button>
</div>
{/* Rocket Info */}
<div className="mb-6 p-4 rounded-xl bg-white/5 border border-white/10">
<h3 className="text-white/60 font-bold text-[10px] uppercase tracking-widest mb-3 border-b border-white/10 pb-2">
{t.vehicleParams}
</h3>
<div className="grid grid-cols-2 gap-y-2 text-[10px] font-mono tracking-widest">
<div className="text-white/40">{t.dryMass}</div>
<div className="text-white/90 text-right">
{formatNumber(totalDryMass / 1000, 1)} t
</div>
<div className="text-white/40">{t.fuelMass}</div>
<div className="text-white/90 text-right">
{formatNumber(totalFuelMass / 1000, 1)} t
</div>
<div className="text-white/40">{t.maxThrust}</div>
<div className="text-white/90 text-right">
{formatNumber(maxPossibleThrust / 1000000, 1)} MN
</div>
<div className="text-white/40">{t.isp}</div>
<div className="text-white/90 text-right">
{rocket.stages[0].isp} s
</div>
<div className="text-white/40">{t.engines}</div>
<div className="text-white/90 text-right">
{rocket.stages[0].engines}
</div>
</div>
</div>
<div className="flex justify-between gap-3 mb-8">
<button
onClick={() => {
toggleSimulation();
}}
disabled={isFinished}
className={`flex-1 flex items-center justify-center gap-2 py-3.5 rounded-xl font-bold tracking-widest text-xs transition-all border ${
isFinished
? "bg-red-500/20 text-red-500 border-red-500/30 opacity-70 cursor-not-allowed"
: state.isRunning
? "bg-amber-500/20 text-amber-400 border-amber-500/30 hover:bg-amber-500/30"
: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/30"
}`}
>
{isFinished ? (
<span className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.8)]" />
{t.ended}
</span>
) : state.isRunning ? (
<>
<Pause className="w-4 h-4" />
{t.pause}
</>
) : (
<>
<Play className="w-4 h-4" />
{t.launch}
</>
)}
</button>
<button
onClick={reset}
className="px-4 py-3.5 rounded-xl font-bold text-white/70 bg-white/5 border border-white/10 hover:bg-white/10 hover:text-white transition-all flex items-center justify-center"
title="Reset Simulation"
>
<RotateCcw className="w-4 h-4" />
</button>
<input
type="file"
accept="audio/*"
className="hidden"
id="bgm-upload"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
const url = URL.createObjectURL(file);
setBgmUrl(url);
setAudioOn(true); // Automatically turn on global audio so they can hear it
}
}}
/>
<label
htmlFor="bgm-upload"
className="px-4 py-3.5 rounded-xl font-bold text-white/70 bg-white/5 border border-white/10 hover:bg-white/10 hover:text-white transition-all flex items-center justify-center cursor-pointer group relative"
>
<Music className="w-4 h-4" />
{/* Tooltip explaining BGM relation to the global speaker control */}
<div className="absolute top-full mt-2 left-1/2 -translate-x-1/2 bg-black/90 border border-white/20 text-[10px] text-white px-3 py-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
Load BGM (Controlled by global audio button)
</div>
</label>
</div>
<div className="space-y-5">
<div className="flex justify-between items-end">
<label className="text-xs font-bold tracking-widest text-white/70 uppercase">
{t.throttle}
</label>
<div className="text-lg font-mono font-bold text-white tabular-nums drop-shadow-md">
{Math.round(state.throttle * 100)}%
</div>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={state.throttle}
disabled={isFinished}
onChange={(e) => setThrottle(parseFloat(e.target.value))}
className="w-full h-2 bg-white/10 rounded-full appearance-none cursor-pointer accent-white"
/>
</div>
<div className="mt-8 grid grid-cols-4 gap-2 text-[11px] text-white/50 font-mono font-bold uppercase tracking-widest p-4 bg-white/5 rounded-xl border border-white/5">
<div className="flex flex-col text-center">
<span className="mb-1 text-white/30 truncate" title={t.twr}>
{t.twr}
</span>
<span
className={
tWR > 1 ? "text-emerald-400 drop-shadow-md" : "text-white/80"
}
>
{formatNumber(tWR, 2)}
</span>
</div>
<div className="flex flex-col text-center">
<span className="mb-1 text-white/30 truncate" title={t.mach}>
{t.mach}
</span>
<span
className={
mach > 1 ? "text-sky-400 drop-shadow-md" : "text-white/80"
}
>
{formatNumber(mach, 1)}
</span>
</div>
<div className="flex flex-col text-center">
<span className="mb-1 text-white/30 truncate" title={t.gforce}>
{t.gforce}
</span>
<span
className={
gForce > 3 ? "text-rose-400 drop-shadow-md" : "text-white/80"
}
>
{formatNumber(gForce, 1)}
</span>
</div>
<div className="flex flex-col text-center">
<span className="mb-1 text-white/30 truncate" title={t.maxT}>
{t.maxT}
</span>
<span className="text-white/80">
{formatNumber(
rocket.stages[state.currentStage].maxThrust / 1000000,
1,
)}
MN
</span>
</div>
</div>
</div>
{/* Dashboards (Floating Glass Panel Right) */}
<div className="absolute right-6 top-[100px] bottom-36 w-[280px] flex flex-col gap-3 z-50 overflow-y-auto custom-scrollbar pr-2 pb-6">
<div className="h-[160px] shrink-0">
<DashboardChart
data={state.history}
dataKey="altitude"
color="#38bdf8"
unit="m"
title={t.altProfile}
/>
</div>
<div className="h-[160px] shrink-0">
<DashboardChart
data={state.history}
dataKey="velocity"
color="#fbbf24"
unit="m/s"
title={t.velProfile}
/>
</div>
<div className="h-[160px] shrink-0">
<DashboardChart
data={state.history}
dataKey="q"
color="#f43f5e"
unit="Pa"
title={t.qProfile}
/>
</div>
</div>
{/* Timeline (Top Center) */}
<div className="absolute top-6 left-1/2 -translate-x-1/2 z-50 flex items-start gap-3 bg-black/60 backdrop-blur-xl border border-white/10 rounded-2xl px-5 py-4 shadow-2xl scale-[0.85] origin-top">
{["liftoff", "maxq", "stageSep1", "karman", "orbit"].map(
(evName, idx, arr) => {
const ev = state.events.find((e) => e.name === evName);
const isReached = !!ev;
return (
<div key={evName} className="flex items-center gap-4">
<div className="flex flex-col items-center gap-2 w-16">
{/* Light */}
<div
className={`w-3 h-3 rounded-full ${isReached ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.8)]" : "bg-red-500/50 shadow-[0_0_8px_rgba(239,68,68,0.5)]"}`}
/>
{/* Label */}
<div className="text-[9px] font-bold tracking-widest text-white/70 uppercase text-center leading-tight">
{t[evName as keyof typeof t]}
</div>
{/* Time */}
<div className="text-[10px] font-mono text-white/50">
{isReached ? formatTime(ev.time) : "--:--"}
</div>
</div>
{idx < arr.length - 1 && (
<div className="w-[1px] h-10 bg-white/10 mx-2" />
)}
</div>
);
},
)}
</div>
{/* Bottom Center: Broadcast Telemetry Style HUD */}
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center justify-center gap-8 bg-black/60 backdrop-blur-xl border border-white/10 rounded-[1.5rem] px-6 py-4 shadow-2xl">
{/* Speed */}
<TelemetryGauge
label={t.speed}
value={state.velocity * 3.6} // m/s to km/h
unit="km/h"
maxValue={40000}
/>
{/* Center Stage Info */}
<div className="flex flex-col items-center justify-center w-40">
<div className="text-2xl leading-none font-mono font-black text-white tracking-widest tabular-nums tabular-nums drop-shadow-xl pb-2">
{formatTime(state.time)}
</div>
<div className="w-full flex flex-col gap-2">
{/* Throttle Bar */}
<div className="w-full">
<div className="flex justify-between text-[8px] text-white/50 font-bold tracking-widest mb-0.5">
<span>{t.hudThrottle}</span>
<span className="text-white drop-shadow-md">
{Math.round(state.throttle * 100)}%
</span>
</div>
<div className="h-0.5 w-full bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-white transition-all duration-75 relative shadow-[0_0_8px_rgba(255,255,255,0.8)]"
style={{ width: `${state.throttle * 100}%` }}
/>
</div>
</div>
{/* Fuel Bar */}
<div className="w-full">
<div className="flex justify-between text-[8px] text-white/50 font-bold tracking-widest mb-0.5">
<span>
{t.hudProp} (S{state.currentStage + 1})
</span>
<span className="text-white drop-shadow-md">
{Math.round(
(state.fuel / rocket.stages[state.currentStage].fuelMass) *
100,
)}
%
</span>
</div>
<div className="h-0.5 w-full bg-white/10 rounded-full overflow-hidden relative">
<div
className="h-full bg-sky-500 transition-all duration-75 absolute top-0 bottom-0 left-0 shadow-[0_0_8px_rgba(14,165,233,0.8)]"
style={{
width: `${(state.fuel / rocket.stages[state.currentStage].fuelMass) * 100}%`,
}}
/>
</div>
</div>
</div>
</div>
{/* Altitude */}
<TelemetryGauge
label={t.altitude}
value={state.altitude / 1000} // m to km
unit="km"
maxValue={1000}
/>
{/* Navball */}
<Navball pitch={state.pitch} />
</div>
</div>
);
}

View File

@ -1,103 +0,0 @@
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { DataPoint } from "../types";
interface ChartProps {
data: DataPoint[];
dataKey: keyof DataPoint;
color: string;
unit: string;
title: string;
}
export function DashboardChart({
data,
dataKey,
color,
unit,
title,
}: ChartProps) {
// Simple formatter for Y Axis
const formatYAxis = (tickItem: number) => {
if (tickItem > 1000) {
return `${(tickItem / 1000).toFixed(1)}k`;
}
return tickItem.toFixed(0);
};
return (
<div className="flex flex-col bg-black/40 backdrop-blur-md border border-white/10 rounded-2xl p-5 w-full h-full shadow-2xl">
<h3 className="text-white/70 text-[11px] font-bold tracking-widest uppercase mb-4 drop-shadow-sm">
{title}
</h3>
<div className="flex-grow min-h-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{ top: 5, right: 5, left: -20, bottom: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="rgba(255,255,255,0.1)"
vertical={false}
/>
<XAxis
dataKey="time"
type="number"
domain={["dataMin", "dataMax"]}
tickFormatter={(t) => `${t.toFixed(0)}s`}
stroke="rgba(255,255,255,0.4)"
fontSize={10}
tickMargin={8}
tickLine={false}
axisLine={false}
/>
<YAxis
tickFormatter={formatYAxis}
stroke="rgba(255,255,255,0.4)"
fontSize={10}
width={45}
tickLine={false}
axisLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: "rgba(0,0,0,0.8)",
borderColor: "rgba(255,255,255,0.15)",
borderRadius: "12px",
backdropFilter: "blur(8px)",
}}
itemStyle={{ color: color, fontSize: "12px", fontWeight: "bold" }}
labelStyle={{
color: "rgba(255,255,255,0.6)",
marginBottom: "4px",
fontSize: "10px",
textTransform: "uppercase",
}}
formatter={(value: number) => [
`${value.toFixed(1)} ${unit}`,
title,
]}
labelFormatter={(label: number) => `T+ ${label.toFixed(1)}s`}
/>
<Line
type="monotone"
dataKey={dataKey}
stroke={color}
strokeWidth={2.5}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@ -1,428 +0,0 @@
import { ReactNode } from "react";
import { RocketPreset, SimulationState } from "../types";
interface FlightVisualizerProps {
rocket: RocketPreset;
state: SimulationState;
cameraMode: "dynamic" | "follow";
}
export function FlightVisualizer({
rocket,
state,
cameraMode,
}: FlightVisualizerProps) {
// Atmosphere gradient based on altitude: fades to black at 80km
const getAtmosphereOpacity = () => {
return Math.max(0, 1 - state.altitude / 80000);
};
// Map physical coords to SVG viewbox
const aspect =
typeof window !== "undefined" ? window.innerWidth / window.innerHeight : 2;
const minVHeight = cameraMode === "dynamic" ? 2500 : 500;
const viewHeight =
cameraMode === "dynamic"
? Math.max(minVHeight, state.altitude * 0.8 + 500)
: minVHeight;
const viewWidth = viewHeight * aspect;
const physX = state.downrange || 0;
const physY = state.altitude || 0;
// Center camera on rocket
const minY =
cameraMode === "dynamic"
? -physY - viewHeight * 0.35
: -physY - viewHeight * 0.6;
const minX = physX - viewWidth * 0.5;
const viewBox = `${minX} ${minY} ${viewWidth} ${viewHeight}`;
const trailPoints = (state.history || [])
.map((p) => `${p.downrange},${-p.altitude}`)
.join(" ");
// visualScale controls the visual size of the rocket.
const visualScale =
cameraMode === "dynamic"
? Math.max(1, Math.pow(viewHeight / minVHeight, 0.95)) * 1.5
: 3.5;
const getStageOffset = (stageIndex: number) => {
if (state.currentStage <= stageIndex) return 0;
const sepEventName = `stageSep${stageIndex + 1}`;
const sepEvent = state.events.find((e) => e.name === sepEventName);
if (!sepEvent) return 0;
const dt = state.time - sepEvent.time;
if (dt < 0) return 0;
// Quadratic falling back effect
return (dt * 15 + 0.5 * 10 * Math.pow(Math.min(dt, 20), 2)) * visualScale;
};
// Custom render for each rocket type
const renderRocketSvg = (id: string, scale: number) => {
let activeEngineY = 0;
let nodes: ReactNode = null;
if (id === "saturnv") {
const w = 10 * scale;
const l = 111 * scale;
const s3Bottom = l * 0.3;
const s2Bottom = l * 0.7;
const s1Bottom = l;
if (state.currentStage === 0) activeEngineY = s1Bottom;
else if (state.currentStage === 1) activeEngineY = s2Bottom;
else activeEngineY = s3Bottom;
const offset1 = getStageOffset(0);
const offset2 = getStageOffset(1);
nodes = (
<>
{/* Stage 3 + Apollo */}
<g>
<path
d={`M ${-w / 2} ${s3Bottom} L ${-w / 2} ${l * 0.15} L ${-w / 4} ${l * 0.1} L 0 0 L ${w / 4} ${l * 0.1} L ${w / 2} ${l * 0.15} L ${w / 2} ${s3Bottom} Z`}
fill="#ffffff"
/>
{/* Decal */}
<rect
x={-w * 0.3}
y={l * 0.2}
width={w * 0.6}
height={l * 0.05}
fill="#111"
/>
</g>
{/* Stage 2 */}
<g
transform={`translate(0, ${offset2})`}
style={{ opacity: Math.max(0, 1 - offset2 / (500 * scale)) }}
>
<rect
x={-w / 2}
y={s3Bottom}
width={w}
height={s2Bottom - s3Bottom}
fill="#ffffff"
/>
<rect
x={-w / 2}
y={l * 0.6}
width={w}
height={l * 0.05}
fill="#111"
/>
</g>
{/* Stage 1 */}
<g
transform={`translate(0, ${offset1})`}
style={{ opacity: Math.max(0, 1 - offset1 / (500 * scale)) }}
>
<path
d={`M ${-w / 2} ${s2Bottom} L ${w / 2} ${s2Bottom} L ${w / 2} ${s1Bottom} L ${-w / 2} ${s1Bottom} Z`}
fill="#ffffff"
/>
<rect
x={-w / 2}
y={l * 0.8}
width={w}
height={l * 0.05}
fill="#111"
/>
</g>
</>
);
} else if (id === "starship") {
const w = 9 * scale;
const l = 120 * scale;
const s2Bottom = l * 0.4;
const s1Bottom = l;
if (state.currentStage === 0) activeEngineY = s1Bottom;
else activeEngineY = s2Bottom;
const offset1 = getStageOffset(0);
nodes = (
<>
{/* Stage 2 (Starship) */}
<g>
<path
d={`M ${-w / 2} ${s2Bottom} L ${-w / 2} ${l * 0.15} Q 0 0 ${w / 2} ${l * 0.15} L ${w / 2} ${s2Bottom} Z`}
fill="#94a3b8"
/>
<path
d={`M ${-w / 2} ${s2Bottom * 0.8} L ${-w * 0.8} ${s2Bottom * 0.9} L ${-w / 2} ${s2Bottom * 0.95} Z`}
fill="#64748b"
/>
<path
d={`M ${w / 2} ${s2Bottom * 0.8} L ${w * 0.8} ${s2Bottom * 0.9} L ${w / 2} ${s2Bottom * 0.95} Z`}
fill="#64748b"
/>
<path
d={`M ${-w / 4} ${l * 0.15} L ${-w * 0.6} ${l * 0.2} L ${-w / 3} ${l * 0.25} Z`}
fill="#64748b"
/>
<path
d={`M ${w / 4} ${l * 0.15} L ${w * 0.6} ${l * 0.2} L ${w / 3} ${l * 0.25} Z`}
fill="#64748b"
/>
</g>
{/* Stage 1 (Super Heavy) */}
<g
transform={`translate(0, ${offset1})`}
style={{ opacity: Math.max(0, 1 - offset1 / (500 * scale)) }}
>
<rect
x={-w / 2}
y={s2Bottom}
width={w}
height={s1Bottom - s2Bottom}
fill="#94a3b8"
/>
<rect
x={-w / 2}
y={s2Bottom}
width={w}
height={l * 0.02}
fill="#111"
/>
</g>
</>
);
} else {
// Falcon 9 default
const w = 3.7 * scale;
const l = 70 * scale;
const s2Bottom = l * 0.35;
const s1Bottom = l;
if (state.currentStage === 0) activeEngineY = s1Bottom;
else activeEngineY = s2Bottom;
const offset1 = getStageOffset(0);
nodes = (
<>
{/* Stage 2 + Fairing */}
<g>
<path
d={`M ${-w / 2} ${s2Bottom} L ${-w / 2} ${l * 0.15} Q 0 0 ${w / 2} ${l * 0.15} L ${w / 2} ${s2Bottom} Z`}
fill="#ffffff"
/>
</g>
{/* Stage 1 */}
<g
transform={`translate(0, ${offset1})`}
style={{ opacity: Math.max(0, 1 - offset1 / (500 * scale)) }}
>
<rect
x={-w / 2}
y={s2Bottom}
width={w}
height={s1Bottom - s2Bottom}
fill="#ffffff"
/>
{/* Interstage (top of Stage 1) */}
<rect
x={-w / 2}
y={s2Bottom}
width={w}
height={l * 0.1}
fill="#111111"
/>
{/* Landing Legs */}
<path
d={`M ${-w / 2} ${l} L ${-w} ${l * 1.05} L ${-w / 2} ${l * 0.95} Z`}
fill="#222"
/>
<path
d={`M ${w / 2} ${l} L ${w} ${l * 1.05} L ${w / 2} ${l * 0.95} Z`}
fill="#222"
/>
</g>
</>
);
}
return { activeEngineY, nodes };
};
const getFlameScale = () => {
if (rocket.id === "saturnv") return 11 * visualScale;
if (rocket.id === "starship") return 10 * visualScale;
return 4 * visualScale; // falcon 9
};
const flameWidth = getFlameScale();
const { activeEngineY, nodes } = renderRocketSvg(rocket.id, visualScale);
return (
<div className="absolute inset-0 w-full h-full bg-[#030712] overflow-hidden z-0">
{/* Atmosphere background */}
<div
className="absolute inset-0 transition-colors duration-500 ease-out"
style={{
backgroundColor: `rgba(2, 132, 199, ${getAtmosphereOpacity()})`, // sky-600 fading to black
}}
/>
{/* Stars - Deep layer */}
<div
className="absolute inset-0 z-0"
style={{
background:
"radial-gradient(1px 1px at 15% 25%, white, transparent), radial-gradient(1px 1px at 75% 15%, rgba(255,255,255,0.8), transparent), radial-gradient(1px 1px at 45% 85%, rgba(255,255,255,0.6), transparent)",
backgroundSize: "150px 150px",
opacity: 1 - getAtmosphereOpacity(),
transform: `translateY(${(physY * 0.02) % 150}px) translateX(${-(physX * 0.02) % 150}px)`,
}}
/>
{/* Stars - Mid layer */}
<div
className="absolute inset-0 z-0"
style={{
background:
"radial-gradient(2px 2px at 30% 60%, rgba(255,255,255,0.9), transparent), radial-gradient(1.5px 1.5px at 80% 40%, rgba(255,255,255,0.7), transparent)",
backgroundSize: "200px 200px",
opacity: 1 - getAtmosphereOpacity(),
transform: `translateY(${(physY * 0.04) % 200}px) translateX(${-(physX * 0.04) % 200}px)`,
}}
/>
{/* Stars - Close layer */}
<div
className="absolute inset-0 z-0"
style={{
background:
"radial-gradient(2.5px 2.5px at 10% 80%, white, transparent), radial-gradient(3px 3px at 90% 10%, rgba(255,255,255,0.9), transparent)",
backgroundSize: "350px 350px",
opacity: (1 - getAtmosphereOpacity()) * 0.8,
transform: `translateY(${(physY * 0.08) % 350}px) translateX(${-(physX * 0.08) % 350}px)`,
}}
/>
{/* Screen Effects (Cinematic Vignette) */}
<div className="absolute inset-0 pointer-events-none z-30 bg-[radial-gradient(circle_at_center,transparent_40%,rgba(0,0,0,0.4)_100%)]" />
<svg
className="absolute inset-0 w-full h-full z-10"
viewBox={viewBox}
preserveAspectRatio="xMidYMid slice"
>
{/* Ground */}
<rect
x={minX - viewWidth}
y={0}
width={viewWidth * 3}
height={Math.max(viewHeight, 1000)}
fill="#020617"
/>
<line
x1={minX - viewWidth}
y1={0}
x2={minX + viewWidth * 2}
y2={0}
stroke="#334155"
strokeWidth={2 * visualScale}
/>
{/* Launch Pad Base */}
<rect x={-20} y={-10} width={40} height={10} fill="#475569" />
<rect x={30} y={-100} width={8} height={100} fill="#334155" />
{/* Trajectory Trail */}
{trailPoints.length > 0 && (
<polyline
points={trailPoints}
fill="none"
stroke="rgba(255, 255, 255, 0.3)"
strokeWidth={4 * visualScale}
strokeDasharray={`${12 * visualScale} ${12 * visualScale}`}
/>
)}
{/* The Rocket Container */}
<g
transform={`translate(${physX}, ${-physY}) rotate(${90 - (state.pitch || 90)})`}
>
{/* Engine Flame */}
{state.throttle > 0 && state.fuel > 0 && (
<g transform={`translate(0, 0)`}>
{/* Outer Glow */}
<ellipse
cx={0}
cy={flameWidth * 4 * state.throttle}
rx={flameWidth * 1.5}
ry={flameWidth * 4.5 * state.throttle}
fill="url(#flameOuter)"
style={{ transformOrigin: "top" }}
className="animate-pulse"
/>
{/* Inner Core */}
<ellipse
cx={0}
cy={flameWidth * 3 * state.throttle}
rx={flameWidth * 0.8}
ry={flameWidth * 3 * state.throttle}
fill="url(#flameInner)"
style={{ transformOrigin: "top" }}
/>
{/* Mach Diamonds (visible at higher speeds/altitudes) */}
{state.throttle > 0.5 &&
state.altitude > 8000 &&
state.altitude < 60000 && (
<g fill="rgba(255,255,255,0.7)">
<ellipse
cx={0}
cy={flameWidth * 1.5}
rx={flameWidth * 0.4}
ry={flameWidth * 0.2}
/>
<ellipse
cx={0}
cy={flameWidth * 3.0}
rx={flameWidth * 0.3}
ry={flameWidth * 0.15}
/>
<ellipse
cx={0}
cy={flameWidth * 4.5}
rx={flameWidth * 0.2}
ry={flameWidth * 0.1}
/>
</g>
)}
</g>
)}
{/* Render Detailed Rocket Specific to Model with Active Engine aligned to (0,0) */}
<g transform={`translate(0, ${-activeEngineY})`}>{nodes}</g>
</g>
<defs>
<radialGradient id="flameOuter" cx="50%" cy="0%" r="100%">
<stop offset="0%" stopColor="rgba(255, 255, 255, 1)" />
<stop offset="20%" stopColor="rgba(253, 224, 71, 0.9)" />
<stop offset="60%" stopColor="rgba(249, 115, 22, 0.4)" />
<stop offset="100%" stopColor="transparent" />
</radialGradient>
<radialGradient id="flameInner" cx="50%" cy="0%" r="100%">
<stop offset="0%" stopColor="white" />
<stop offset="50%" stopColor="#fef08a" />
<stop offset="100%" stopColor="transparent" />
</radialGradient>
</defs>
</svg>
</div>
);
}

View File

@ -1,46 +0,0 @@
import React from "react";
interface NavballProps {
pitch: number; // 90 is up, 0 is horizontal, -90 is down
}
export function Navball({ pitch }: NavballProps) {
// For SpaceX broadcast style, we show a rocket silhouette that rotates based on pitch.
// pitch 90 = pointing straight up = 0 deg rotation
// pitch 0 = pointing horizontal = 90 deg rotation
const rotation = 90 - pitch;
return (
<div className="flex flex-col items-center justify-center w-24">
<div className="relative w-16 h-16 rounded-full border border-white/20 flex items-center justify-center bg-black/40 shadow-inner overflow-hidden">
{/* Horizontal reference line */}
<div className="absolute w-full h-[1px] bg-white/20" />
{/* Vertical reference line */}
<div className="absolute h-full w-[1px] bg-white/20" />
{/* Rocket outline rotating */}
<div
className="absolute transition-transform duration-100 ease-linear flex items-center justify-center text-white"
style={{ transform: `rotate(${rotation}deg)` }}
>
{/* Simple rocket silhouette */}
<svg width="24" height="40" viewBox="0 0 24 40" fill="currentColor">
{/* Rocket body */}
<path
d="M12 2 C12 2, 8 10, 8 25 L8 35 L16 35 L16 25 C16 10, 12 2, 12 2z"
fill="#f8fafc"
/>
{/* Fins */}
<path d="M8 28 L4 35 L8 35z" fill="#f8fafc" />
<path d="M16 28 L20 35 L16 35z" fill="#f8fafc" />
{/* Engine bell */}
<path d="M10 35 L14 35 L15 38 L9 38z" fill="#9ca3af" />
</svg>
</div>
</div>
<div className="text-white/80 text-[10px] font-bold font-mono tracking-widest mt-3">
PITCH {Math.round(pitch)}°
</div>
</div>
);
}

View File

@ -1,68 +0,0 @@
import React from "react";
interface TelemetryGaugeProps {
label: string;
value: number;
unit: string;
maxValue: number;
}
export function TelemetryGauge({
label,
value,
unit,
maxValue,
}: TelemetryGaugeProps) {
const radius = 32;
const stroke = 3;
const normalizedRadius = radius - stroke * 2;
const circumference = normalizedRadius * 2 * Math.PI;
const fillRatio = Math.min(Math.max(value / maxValue, 0), 1);
const strokeDashoffset = circumference - fillRatio * circumference;
return (
<div className="flex flex-col items-center justify-center relative w-20">
<div className="relative w-16 h-16">
<svg
height="100%"
width="100%"
className="absolute inset-0 transform -rotate-90"
>
<circle
stroke="rgba(255, 255, 255, 0.15)"
fill="transparent"
strokeWidth={stroke}
r={normalizedRadius}
cx="50%"
cy="50%"
/>
<circle
stroke="white"
fill="transparent"
strokeWidth={stroke}
strokeDasharray={circumference + " " + circumference}
style={{
strokeDashoffset,
transition: "stroke-dashoffset 0.1s linear",
}}
strokeLinecap="round"
r={normalizedRadius}
cx="50%"
cy="50%"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-[1.1rem] font-mono font-bold text-white tracking-tighter drop-shadow-md tabular-nums leading-none">
{Math.floor(value).toLocaleString()}
</span>
<span className="text-[8px] uppercase tracking-widest text-white/50 font-semibold mt-0.5">
{unit}
</span>
</div>
</div>
<div className="text-white/80 text-[9px] font-bold font-sans tracking-[0.2em] whitespace-nowrap mt-2">
{label}
</div>
</div>
);
}

View File

@ -1,78 +0,0 @@
export const i18n = {
en: {
appTitle: "Orbital Ascent",
appSub: "Orbital Launch Telemetry",
directives: "Flight Directives",
launch: "LAUNCH",
pause: "PAUSE",
throttle: "Engine Throttle",
twr: "TWR",
mach: "MACH",
gforce: "G-FORCE",
maxT: "MAX T",
altProfile: "Altitude Profile",
velProfile: "Velocity Profile",
qProfile: "Dynamic Pressure (Q)",
speed: "SPEED",
altitude: "ALTITUDE",
hudThrottle: "THROTTLE",
hudProp: "PROPELLANT",
liftoff: "Liftoff",
maxq: "Max-Q",
meco: "MECO",
karman: "Karman Line",
stageSep1: "Stage 1 Sep",
stageSep2: "Stage 2 Sep",
stageSep3: "Stage 3 Sep",
orbit: "Orbital Insertion",
simEnded: "Simulation Ended",
vehicleParams: "Vehicle Parameters",
dryMass: "Dry Mass",
fuelMass: "Propellant",
maxThrust: "Max Thrust",
isp: "Vacuum ISP",
engines: "Engines",
cameraDynamic: "Dynamic Cam",
cameraFollow: "Follow Cam",
ended: "ENDED",
},
zh: {
appTitle: "火箭发射模拟器",
appSub: "轨道发射实时遥测",
directives: "飞行控制",
launch: "发射",
pause: "暂停",
throttle: "主引擎推力调节",
twr: "推重比 (TWR)",
mach: "马赫数 (MACH)",
gforce: "G力 (G-FORCE)",
maxT: "最大推力",
altProfile: "高度-时间曲线",
velProfile: "速度-时间曲线",
qProfile: "空气动压 (Q)",
speed: "速度",
altitude: "高度",
hudThrottle: "当前推力",
hudProp: "推进剂",
liftoff: "起飞",
maxq: "最大动压",
meco: "引擎熄火",
karman: "卡门线",
stageSep1: "一级分离",
stageSep2: "二级分离",
stageSep3: "三级分离",
orbit: "入轨",
simEnded: "结束",
vehicleParams: "载具参数",
dryMass: "干质量",
fuelMass: "燃料质量",
maxThrust: "最大推力",
isp: "真空比冲",
engines: "发动机数量",
cameraDynamic: "动态视角",
cameraFollow: "跟随视角",
ended: "已结束",
},
};
export type Lang = "en" | "zh";

View File

@ -1,20 +0,0 @@
@import "tailwindcss";
@layer utilities {
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #475569;
}
}

View File

@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -1,10 +0,0 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@ -1,140 +0,0 @@
export interface RocketStage {
name: string;
dryMass: number; // kg
fuelMass: number; // kg
maxThrust: number; // N
isp: number; // seconds
engines: number;
}
export interface RocketPreset {
id: string;
name: string;
stages: RocketStage[];
area: number; // m^2
cd: number; // drag coefficient
color: string;
}
export interface SimulationState {
time: number; // s
altitude: number; // m
downrange: number; // m
velocity: number; // m/s
vx: number;
vy: number;
pitch: number; // degrees, 90 is straight up
acceleration: number; // m/s^2
q: number; // dynamic pressure (Pa)
currentStage: number; // index of active stage
fuel: number; // kg of current active stage
throttle: number; // 0 to 1
isRunning: boolean;
history: DataPoint[]; // For charts
events: { name: string; time: number }[];
}
export interface DataPoint {
time: number;
altitude: number; // m
downrange: number; // m
velocity: number; // m/s
acceleration: number; // m/s^2
thrust: number; // N
q: number; // Pa
}
export const CONSTANTS = {
G: 6.6743e-11,
M_EARTH: 5.972e24,
R_EARTH: 6371000,
RHO_0: 1.225, // Sea level density kg/m^3
H: 8500, // Scale height m
g0: 9.80665, // Standard gravity
};
export const PRESETS: RocketPreset[] = [
{
id: "falcon9",
name: "Falcon 9",
stages: [
{
name: "Stage 1",
dryMass: 25600,
fuelMass: 411000,
maxThrust: 7607000,
isp: 282,
engines: 9,
},
{
name: "Stage 2",
dryMass: 4000,
fuelMass: 107500,
maxThrust: 981000,
isp: 348,
engines: 1,
},
],
area: 10.75,
cd: 0.4,
color: "#ffffff", // falcon 9 white
},
{
id: "saturnv",
name: "Saturn V",
stages: [
{
name: "S-IC (Stage 1)",
dryMass: 131000,
fuelMass: 2149000,
maxThrust: 35100000,
isp: 263,
engines: 5,
},
{
name: "S-II (Stage 2)",
dryMass: 36000,
fuelMass: 444000,
maxThrust: 5100000,
isp: 421,
engines: 5,
},
{
name: "S-IVB (Stage 3)",
dryMass: 10000,
fuelMass: 109000,
maxThrust: 1000000,
isp: 421,
engines: 1,
},
],
area: 80,
cd: 0.5,
color: "#f59e0b", // amber-500
},
{
id: "starship",
name: "Starship",
stages: [
{
name: "Super Heavy",
dryMass: 200000,
fuelMass: 3400000,
maxThrust: 74000000,
isp: 330,
engines: 33,
},
{
name: "Starship",
dryMass: 100000,
fuelMass: 1200000,
maxThrust: 15000000,
isp: 380,
engines: 6,
},
],
area: 63.6,
cd: 0.4,
color: "#a8a29e", // stone-400
},
];

View File

@ -1,332 +0,0 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { RocketPreset, SimulationState, DataPoint, CONSTANTS } from "./types";
export function useSimulation(initialPreset: RocketPreset) {
const [rocket, setRocket] = useState<RocketPreset>(initialPreset);
const [state, setState] = useState<SimulationState>({
time: 0,
altitude: 0,
downrange: 0,
velocity: 0,
vx: 0,
vy: 0,
pitch: 90,
acceleration: 0,
q: 0,
fuel: initialPreset.stages[0].fuelMass,
currentStage: 0,
throttle: 0,
isRunning: false,
history: [],
events: [],
});
const stateRef = useRef(state);
const rocketRef = useRef(rocket);
const lastTimeRef = useRef<number>(0);
const frameRef = useRef<number>(0);
const maxQRef = useRef<number>(0);
const setThrottle = useCallback((val: number) => {
setState((prev) => {
const newState = { ...prev, throttle: Math.max(0, Math.min(1, val)) };
stateRef.current = newState;
return newState;
});
}, []);
const changePreset = useCallback((preset: RocketPreset) => {
maxQRef.current = 0;
setRocket(preset);
rocketRef.current = preset;
const newState = {
time: 0,
altitude: 0,
downrange: 0,
velocity: 0,
vx: 0,
vy: 0,
pitch: 90,
acceleration: 0,
q: 0,
currentStage: 0,
fuel: preset.stages[0].fuelMass,
throttle: 0,
isRunning: false,
history: [],
events: [],
};
stateRef.current = newState;
setState(newState);
}, []);
const reset = useCallback(() => {
maxQRef.current = 0;
const newState = {
time: 0,
altitude: 0,
downrange: 0,
velocity: 0,
vx: 0,
vy: 0,
pitch: 90,
acceleration: 0,
q: 0,
currentStage: 0,
fuel: rocketRef.current.stages[0].fuelMass,
throttle: 0,
isRunning: false,
history: [],
events: [],
};
stateRef.current = newState;
setState(newState);
}, []);
const toggleSimulation = useCallback(() => {
setState((prev) => {
const isFinished = prev.events.some((e) => e.name === "simEnded");
if (isFinished) return prev;
const isRunning = !prev.isRunning;
if (isRunning) {
lastTimeRef.current = performance.now();
}
const newState = {
...prev,
isRunning,
throttle: isRunning && prev.throttle === 0 ? 1 : prev.throttle,
};
stateRef.current = newState;
return newState;
});
}, []);
useEffect(() => {
const loop = (time: number) => {
if (!stateRef.current.isRunning) {
frameRef.current = requestAnimationFrame(loop);
return;
}
const { G, M_EARTH, R_EARTH, RHO_0, H, g0 } = CONSTANTS;
const dtSystem = (time - lastTimeRef.current) / 1000;
lastTimeRef.current = time;
// Physics Sub-stepping to prevent Euler integration explosion
const PHYSICS_STEPS = 5;
const dt = Math.min(dtSystem, 0.1) / PHYSICS_STEPS;
let {
time: t,
altitude: h,
downrange: x,
vx,
vy,
pitch,
currentStage,
fuel,
throttle,
history,
} = stateRef.current;
const { area, cd, stages, payloadMass } = rocketRef.current;
const stage = stages[currentStage];
const { dryMass, maxThrust, isp } = stage;
let upperStagesMass = 0;
for (let i = currentStage + 1; i < stages.length; i++) {
upperStagesMass += stages[i].dryMass + stages[i].fuelMass;
}
const mDotMax = maxThrust / (isp * g0);
let actualThrottle = throttle;
let currentEvents = [...stateRef.current.events];
let currentQ = stateRef.current.q;
let a = stateRef.current.acceleration;
let finalThrust = 0;
for (let step = 0; step < PHYSICS_STEPS; step++) {
const m = dryMass + fuel + upperStagesMass + (payloadMass || 0);
if (fuel <= 0 && currentStage < stages.length - 1) {
currentStage++;
fuel = stages[currentStage].fuelMass;
if (!currentEvents.find((e) => e.name === `stageSep${currentStage}`)) {
currentEvents.push({ name: `stageSep${currentStage}`, time: t });
}
} else if (fuel <= 0) {
actualThrottle = 0;
fuel = 0;
}
const currentThrust = maxThrust * actualThrottle;
finalThrust = currentThrust;
const mDot = mDotMax * actualThrottle;
if (actualThrottle > 0) {
fuel -= mDot * dt;
if (fuel < 0) fuel = 0;
}
let v = Math.sqrt(vx * vx + vy * vy);
// Improved pitch program to prevent deep atmospheric dips and orbit bounce
const pitching = h > 500 && v > 50;
if (pitching) {
// Smooth horizontal transition aiming for 0 pitch at ~120km
pitch = Math.max(0, 90 * (1 - Math.min(1, (h - 500) / 120000)));
}
if (h <= 500) {
pitch = 90;
}
const pitchRad = (pitch * Math.PI) / 180;
const fg = (G * (M_EARTH * m)) / Math.pow(R_EARTH + h, 2);
const centrifugalY = (m * vx * vx) / (R_EARTH + h);
const rho = h > 100000 ? 0 : RHO_0 * Math.exp(-h / H);
// Dynamic pressure (q) in Pa
currentQ = 0.5 * rho * v * v;
let fd = currentQ * cd * area;
// Prevent drag from reversing velocity in a single step (numerical stability)
const maxDragForce = (m * v) / dt;
if (fd > maxDragForce * 0.9) fd = maxDragForce * 0.9;
const thrustX = currentThrust * Math.cos(pitchRad);
const thrustY = currentThrust * Math.sin(pitchRad);
const vDirX = v > 0 ? vx / v : 0;
const vDirY = v > 0 ? vy / v : 1;
const dragX = fd * vDirX;
const dragY = fd * vDirY;
const netForceX = thrustX - dragX;
const netForceY = thrustY - dragY - fg + centrifugalY;
let ax = netForceX / m;
let ay = netForceY / m;
// Ground constraint
if (h <= 0 && vy <= 0 && netForceY <= 0) {
ay = 0;
ax = 0;
vx = 0;
vy = 0;
h = 0;
pitch = 90;
}
vx += ax * dt;
vy += ay * dt;
x += vx * dt;
h += vy * dt;
if (h < 0) h = 0;
t += dt;
a = Math.sqrt(ax * ax + ay * ay);
}
let v = Math.sqrt(vx * vx + vy * vy);
// Event Tracking
if (h > 10 && v > 1 && !currentEvents.find((e) => e.name === "liftoff")) {
currentEvents.push({ name: "liftoff", time: t });
}
if (currentQ > maxQRef.current) {
maxQRef.current = currentQ;
}
// Trigger Max-Q if it drops to 95% of peak after reaching at least 20kPa
if (
maxQRef.current > 20000 &&
currentQ < maxQRef.current * 0.95 &&
!currentEvents.find((e) => e.name === "maxq")
) {
currentEvents.push({ name: "maxq", time: t });
}
if (
currentStage === stages.length - 1 &&
fuel <= 0 &&
t > 10 &&
!currentEvents.find((e) => e.name === "meco")
) {
currentEvents.push({ name: "meco", time: t });
}
if (h >= 100000 && !currentEvents.find((e) => e.name === "karman")) {
currentEvents.push({ name: "karman", time: t });
}
if (vx >= 7800 && !currentEvents.find((e) => e.name === "orbit")) {
currentEvents.push({ name: "orbit", time: t });
}
let shouldEnd = false;
const mecoEvent = currentEvents.find((e) => e.name === "meco");
if (
mecoEvent &&
t > mecoEvent.time + 3 &&
!currentEvents.find((e) => e.name === "simEnded")
) {
currentEvents.push({ name: "simEnded", time: t });
shouldEnd = true;
}
const lastHistoryTime =
history.length > 0 ? history[history.length - 1].time : -1;
let newHistory = history;
if (t - lastHistoryTime >= 0.5) {
newHistory = [
...history,
{
time: t,
altitude: h,
downrange: x,
velocity: v,
acceleration: a,
thrust: finalThrust,
q: currentQ,
},
];
}
const newState = {
...stateRef.current,
time: t,
altitude: h,
downrange: x,
velocity: v,
vx,
vy,
pitch,
acceleration: a,
q: currentQ,
currentStage,
fuel,
throttle:
fuel <= 0 && currentStage === stages.length - 1 ? 0 : throttle,
isRunning: shouldEnd ? false : stateRef.current.isRunning,
history: newHistory,
events: currentEvents,
};
stateRef.current = newState;
setState(newState);
frameRef.current = requestAnimationFrame(loop);
};
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, []);
return {
rocket,
state,
setThrottle,
changePreset,
toggleSimulation,
reset,
};
}

View File

@ -1,26 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

View File

@ -1,22 +0,0 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
export default defineConfig(() => {
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
},
};
});