1.1.0
parent
d93b4f787a
commit
5433892bf6
|
|
@ -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"
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
node_modules/
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
coverage/
|
|
||||||
.DS_Store
|
|
||||||
*.log
|
|
||||||
.env*
|
|
||||||
!.env.example
|
|
||||||
|
|
@ -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`
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"name": "",
|
|
||||||
"description": "",
|
|
||||||
"requestFramePermissions": [],
|
|
||||||
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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";
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { clsx, type ClassValue } from "clsx";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
|
|
@ -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>,
|
|
||||||
);
|
|
||||||
|
|
@ -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
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 : {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue