Many improvments + install script
This commit is contained in:
202
install-service.sh
Normal file
202
install-service.sh
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Rocketry Service Installer
|
||||||
|
# Installs the React SPA as a systemd service on the system
|
||||||
|
|
||||||
|
# Preflight checks
|
||||||
|
command -v node >/dev/null || { echo "Error: node not found"; exit 1; }
|
||||||
|
command -v npm >/dev/null || { echo "Error: npm not found"; exit 1; }
|
||||||
|
command -v sudo >/dev/null || { echo "Error: sudo not found"; exit 1; }
|
||||||
|
|
||||||
|
echo "Rocketry Service Installer"
|
||||||
|
echo "=========================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Port prompt
|
||||||
|
read -p "Port to run on [default: 8080]: " PORT
|
||||||
|
PORT=${PORT:-8080}
|
||||||
|
|
||||||
|
# Validate port
|
||||||
|
if ! [[ "$PORT" =~ ^[0-9]+$ ]] || [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then
|
||||||
|
echo "Error: Invalid port number. Must be between 1 and 65535."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Building application..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Install directory
|
||||||
|
INSTALL_DIR="/opt/rocketry"
|
||||||
|
echo "Installing to $INSTALL_DIR..."
|
||||||
|
sudo mkdir -p "$INSTALL_DIR"
|
||||||
|
sudo cp -r dist "$INSTALL_DIR/"
|
||||||
|
|
||||||
|
# Write server.js to /opt/rocketry/server.js
|
||||||
|
echo "Writing server.js..."
|
||||||
|
sudo tee "$INSTALL_DIR/server.js" > /dev/null <<'SERVEREOF'
|
||||||
|
#!/usr/bin/env node
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 8080;
|
||||||
|
const DIST_DIR = path.join(__dirname, 'dist');
|
||||||
|
|
||||||
|
const MIME_TYPES = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
'.json': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
// Normalize URL
|
||||||
|
let urlPath = decodeURIComponent(req.url);
|
||||||
|
if (urlPath === '/') {
|
||||||
|
urlPath = '/index.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve file path
|
||||||
|
const filePath = path.join(DIST_DIR, urlPath);
|
||||||
|
|
||||||
|
// Security check: ensure we're within DIST_DIR
|
||||||
|
if (!filePath.startsWith(DIST_DIR)) {
|
||||||
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Forbidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.stat(filePath, (err, stats) => {
|
||||||
|
if (err) {
|
||||||
|
// File not found; serve index.html for SPA routing (unless it's a file with extension)
|
||||||
|
const ext = path.extname(urlPath);
|
||||||
|
if (ext && ext !== '.html') {
|
||||||
|
// Likely a missing asset file
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Not Found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPA fallback: serve index.html
|
||||||
|
const indexPath = path.join(DIST_DIR, 'index.html');
|
||||||
|
fs.readFile(indexPath, 'utf-8', (readErr, data) => {
|
||||||
|
if (readErr) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Internal Server Error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
|
res.end(data);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
// Try to serve index.html from the directory
|
||||||
|
const indexPath = path.join(filePath, 'index.html');
|
||||||
|
fs.readFile(indexPath, 'utf-8', (readErr, data) => {
|
||||||
|
if (readErr) {
|
||||||
|
// Directory exists but no index.html; fall back to SPA index
|
||||||
|
const mainIndexPath = path.join(DIST_DIR, 'index.html');
|
||||||
|
fs.readFile(mainIndexPath, 'utf-8', (mainErr, mainData) => {
|
||||||
|
if (mainErr) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Not Found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
|
res.end(mainData);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
|
res.end(data);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve file with appropriate MIME type
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
|
fs.readFile(filePath, (readErr, data) => {
|
||||||
|
if (readErr) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Internal Server Error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': contentType });
|
||||||
|
res.end(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Rocketry server listening on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('error', (err) => {
|
||||||
|
if (err.code === 'EACCES') {
|
||||||
|
console.error(`Error: Permission denied. Port ${PORT} may require elevated privileges.`);
|
||||||
|
} else if (err.code === 'EADDRINUSE') {
|
||||||
|
console.error(`Error: Port ${PORT} is already in use.`);
|
||||||
|
} else {
|
||||||
|
console.error('Server error:', err.message);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
SERVEREOF
|
||||||
|
|
||||||
|
# Make server.js executable
|
||||||
|
sudo chmod +x "$INSTALL_DIR/server.js"
|
||||||
|
|
||||||
|
# Write systemd unit file
|
||||||
|
echo "Writing systemd service file..."
|
||||||
|
sudo bash -c "cat > /etc/systemd/system/rocketry.service" <<UNITEOF
|
||||||
|
[Unit]
|
||||||
|
Description=Rocketry Equation Solver
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=nobody
|
||||||
|
WorkingDirectory=/opt/rocketry
|
||||||
|
ExecStart=/usr/bin/node /opt/rocketry/server.js
|
||||||
|
Environment="PORT=$PORT"
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNITEOF
|
||||||
|
|
||||||
|
# Enable and start service
|
||||||
|
echo "Configuring systemd service..."
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable rocketry
|
||||||
|
sudo systemctl restart rocketry
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✓ Rocketry service installed successfully!"
|
||||||
|
echo ""
|
||||||
|
echo " Location: $INSTALL_DIR"
|
||||||
|
echo " Port: $PORT"
|
||||||
|
echo " URL: http://localhost:$PORT"
|
||||||
|
echo ""
|
||||||
|
echo "Service status:"
|
||||||
|
sudo systemctl status rocketry --no-pager | sed 's/^/ /'
|
||||||
|
echo ""
|
||||||
|
echo "Useful commands:"
|
||||||
|
echo " Check status: sudo systemctl status rocketry"
|
||||||
|
echo " View logs: sudo journalctl -u rocketry -f"
|
||||||
|
echo " Restart: sudo systemctl restart rocketry"
|
||||||
|
echo " Stop: sudo systemctl stop rocketry"
|
||||||
|
echo " Uninstall: sudo systemctl disable rocketry && sudo rm -rf $INSTALL_DIR /etc/systemd/system/rocketry.service && sudo systemctl daemon-reload"
|
||||||
14
src/App.jsx
14
src/App.jsx
@@ -4,6 +4,7 @@ import Solver from './pages/Solver.jsx'
|
|||||||
import EnginePage from './pages/EnginePage.jsx'
|
import EnginePage from './pages/EnginePage.jsx'
|
||||||
import RocketPage from './pages/RocketPage.jsx'
|
import RocketPage from './pages/RocketPage.jsx'
|
||||||
import KnowledgebaseFuelsPage from './pages/KnowledgebaseFuelsPage.jsx'
|
import KnowledgebaseFuelsPage from './pages/KnowledgebaseFuelsPage.jsx'
|
||||||
|
import KnowledgebaseEquationsPage from './pages/KnowledgebaseEquationsPage.jsx'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -89,6 +90,18 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
Fuels / Oxidisers
|
Fuels / Oxidisers
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/knowledgebase/equations"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block px-4 py-2 text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-slate-700 text-white'
|
||||||
|
: 'text-slate-300 hover:bg-slate-700 hover:text-white'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Equations
|
||||||
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -99,6 +112,7 @@ export default function App() {
|
|||||||
<Route path="/design/engine" element={<EnginePage />} />
|
<Route path="/design/engine" element={<EnginePage />} />
|
||||||
<Route path="/design/rocket" element={<RocketPage />} />
|
<Route path="/design/rocket" element={<RocketPage />} />
|
||||||
<Route path="/knowledgebase/fuels" element={<KnowledgebaseFuelsPage />} />
|
<Route path="/knowledgebase/fuels" element={<KnowledgebaseFuelsPage />} />
|
||||||
|
<Route path="/knowledgebase/equations" element={<KnowledgebaseEquationsPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
20
src/components/ErrorBoundary.jsx
Normal file
20
src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Component } from 'react'
|
||||||
|
|
||||||
|
export default class ErrorBoundary extends Component {
|
||||||
|
state = { error: null }
|
||||||
|
|
||||||
|
static getDerivedStateFromError(e) {
|
||||||
|
return { error: e }
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full text-slate-500 text-sm">
|
||||||
|
3D view unavailable
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,10 +96,7 @@ export function calcCooling(thermo, cooling, chamberGeom) {
|
|||||||
|
|
||||||
const { Dt, Dc, Lc } = chamberGeom
|
const { Dt, Dc, Lc } = chamberGeom
|
||||||
// Simplified Bartz heat flux [W/m²] using typical exhaust gas properties
|
// Simplified Bartz heat flux [W/m²] using typical exhaust gas properties
|
||||||
const mu = 6e-5 // Pa·s — typical rocket exhaust dynamic viscosity
|
const { mu = 6e-5, cp = 2000, Pr = 0.7, T_wall = 800 } = cooling
|
||||||
const cp = 2000 // J/(kg·K)
|
|
||||||
const Pr = 0.7
|
|
||||||
const T_wall = 800 // K — assumed hot-gas-side wall temperature
|
|
||||||
|
|
||||||
const q_est = (0.026 / Math.pow(Dt, 0.2)) *
|
const q_est = (0.026 / Math.pow(Dt, 0.2)) *
|
||||||
(Math.pow(mu, 0.2) * cp / Math.pow(Pr, 0.6)) *
|
(Math.pow(mu, 0.2) * cp / Math.pow(Pr, 0.6)) *
|
||||||
@@ -110,9 +107,9 @@ export function calcCooling(thermo, cooling, chamberGeom) {
|
|||||||
const q_total = q_est * chamberArea
|
const q_total = q_est * chamberArea
|
||||||
|
|
||||||
const { channelCount } = cooling
|
const { channelCount } = cooling
|
||||||
const channelArea = channelCount > 0 ? q_total / channelCount : 0
|
const q_perChannel = channelCount > 0 ? q_total / channelCount : 0
|
||||||
|
|
||||||
return { method, q_est, q_total, channelCount, channelArea }
|
return { method, q_est, q_total, channelCount, q_perChannel }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'film') {
|
if (method === 'film') {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Fundamental Thrust Equation',
|
name: 'Fundamental Thrust Equation',
|
||||||
formula: 'F = ṁ·Vₑ + (pₑ − pₐ)·Aₑ',
|
formula: 'F = ṁ·Vₑ + (pₑ − pₐ)·Aₑ',
|
||||||
category: 'Thrust',
|
category: 'Thrust',
|
||||||
|
description: 'Calculates total thrust from momentum thrust and pressure thrust. Used when exit conditions and mass flow are known.',
|
||||||
variables: ['F', 'mdot', 'Ve', 'pe', 'pa', 'Ae'],
|
variables: ['F', 'mdot', 'Ve', 'pe', 'pa', 'Ae'],
|
||||||
solvers: {
|
solvers: {
|
||||||
F: v => v.mdot * v.Ve + (v.pe - v.pa) * v.Ae,
|
F: v => v.mdot * v.Ve + (v.pe - v.pa) * v.Ae,
|
||||||
@@ -40,6 +41,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Thrust from Isp',
|
name: 'Thrust from Isp',
|
||||||
formula: 'F = ṁ·Isp·g₀',
|
formula: 'F = ṁ·Isp·g₀',
|
||||||
category: 'Thrust',
|
category: 'Thrust',
|
||||||
|
description: 'Relates thrust, mass flow, and specific impulse. Commonly used when Isp is known instead of exhaust velocity.',
|
||||||
variables: ['F', 'mdot', 'Isp', 'g0'],
|
variables: ['F', 'mdot', 'Isp', 'g0'],
|
||||||
solvers: {
|
solvers: {
|
||||||
F: v => v.mdot * v.Isp * v.g0,
|
F: v => v.mdot * v.Isp * v.g0,
|
||||||
@@ -55,6 +57,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Effective Exhaust Velocity',
|
name: 'Effective Exhaust Velocity',
|
||||||
formula: 'cₑ = F / ṁ',
|
formula: 'cₑ = F / ṁ',
|
||||||
category: 'Thrust',
|
category: 'Thrust',
|
||||||
|
description: 'Defines effective exhaust velocity as thrust divided by mass flow. Includes both momentum and pressure thrust effects.',
|
||||||
variables: ['ceff', 'F', 'mdot'],
|
variables: ['ceff', 'F', 'mdot'],
|
||||||
solvers: {
|
solvers: {
|
||||||
ceff: v => v.F / v.mdot,
|
ceff: v => v.F / v.mdot,
|
||||||
@@ -69,6 +72,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Isp from Effective Exhaust Velocity',
|
name: 'Isp from Effective Exhaust Velocity',
|
||||||
formula: 'Isp = cₑ / g₀',
|
formula: 'Isp = cₑ / g₀',
|
||||||
category: 'Specific Impulse',
|
category: 'Specific Impulse',
|
||||||
|
description: 'Converts effective exhaust velocity to specific impulse. Related by the standard gravitational acceleration.',
|
||||||
variables: ['Isp', 'ceff', 'g0'],
|
variables: ['Isp', 'ceff', 'g0'],
|
||||||
solvers: {
|
solvers: {
|
||||||
Isp: v => v.ceff / v.g0,
|
Isp: v => v.ceff / v.g0,
|
||||||
@@ -82,6 +86,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Isp from Thrust & Mass Flow',
|
name: 'Isp from Thrust & Mass Flow',
|
||||||
formula: 'Isp = F / (ṁ·g₀)',
|
formula: 'Isp = F / (ṁ·g₀)',
|
||||||
category: 'Specific Impulse',
|
category: 'Specific Impulse',
|
||||||
|
description: 'Directly calculates specific impulse from thrust and mass flow. Fundamental performance metric for rocket engines.',
|
||||||
variables: ['Isp', 'F', 'mdot', 'g0'],
|
variables: ['Isp', 'F', 'mdot', 'g0'],
|
||||||
solvers: {
|
solvers: {
|
||||||
Isp: v => v.F / (v.mdot * v.g0),
|
Isp: v => v.F / (v.mdot * v.g0),
|
||||||
@@ -97,6 +102,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Characteristic Velocity',
|
name: 'Characteristic Velocity',
|
||||||
formula: 'c* = p₀·Aₜ / ṁ',
|
formula: 'c* = p₀·Aₜ / ṁ',
|
||||||
category: 'Nozzle Performance',
|
category: 'Nozzle Performance',
|
||||||
|
description: 'Defines characteristic velocity as a measure of combustion chamber performance. Independent of nozzle expansion.',
|
||||||
variables: ['cstar', 'p0', 'At', 'mdot'],
|
variables: ['cstar', 'p0', 'At', 'mdot'],
|
||||||
solvers: {
|
solvers: {
|
||||||
cstar: v => (v.p0 * v.At) / v.mdot,
|
cstar: v => (v.p0 * v.At) / v.mdot,
|
||||||
@@ -111,6 +117,7 @@ export const EQUATIONS = [
|
|||||||
name: 'c* from Thrust Coefficient & Isp',
|
name: 'c* from Thrust Coefficient & Isp',
|
||||||
formula: 'c* = Isp·g₀ / Cꜰ',
|
formula: 'c* = Isp·g₀ / Cꜰ',
|
||||||
category: 'Nozzle Performance',
|
category: 'Nozzle Performance',
|
||||||
|
description: 'Relates characteristic velocity to thrust coefficient and specific impulse. Useful for nozzle analysis.',
|
||||||
variables: ['cstar', 'Isp', 'g0', 'CF'],
|
variables: ['cstar', 'Isp', 'g0', 'CF'],
|
||||||
solvers: {
|
solvers: {
|
||||||
cstar: v => (v.Isp * v.g0) / v.CF,
|
cstar: v => (v.Isp * v.g0) / v.CF,
|
||||||
@@ -126,6 +133,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Thrust Coefficient',
|
name: 'Thrust Coefficient',
|
||||||
formula: 'Cꜰ = F / (p₀·Aₜ)',
|
formula: 'Cꜰ = F / (p₀·Aₜ)',
|
||||||
category: 'Nozzle Performance',
|
category: 'Nozzle Performance',
|
||||||
|
description: 'Defines thrust coefficient as a dimensionless nozzle performance factor. Typical values are 1.4–1.9 for ideal nozzles.',
|
||||||
variables: ['CF', 'F', 'p0', 'At'],
|
variables: ['CF', 'F', 'p0', 'At'],
|
||||||
solvers: {
|
solvers: {
|
||||||
CF: v => v.F / (v.p0 * v.At),
|
CF: v => v.F / (v.p0 * v.At),
|
||||||
@@ -141,6 +149,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Tsiolkovsky Rocket Equation (cₑ)',
|
name: 'Tsiolkovsky Rocket Equation (cₑ)',
|
||||||
formula: 'Δv = cₑ·ln(m₀/mf)',
|
formula: 'Δv = cₑ·ln(m₀/mf)',
|
||||||
category: 'Rocket Equation',
|
category: 'Rocket Equation',
|
||||||
|
description: 'The rocket equation in terms of effective exhaust velocity. Calculates delta-v available from propellant mass ratio.',
|
||||||
variables: ['dv', 'ceff', 'm0', 'mf'],
|
variables: ['dv', 'ceff', 'm0', 'mf'],
|
||||||
solvers: {
|
solvers: {
|
||||||
dv: v => v.ceff * Math.log(v.m0 / v.mf),
|
dv: v => v.ceff * Math.log(v.m0 / v.mf),
|
||||||
@@ -155,6 +164,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Tsiolkovsky Rocket Equation (Isp)',
|
name: 'Tsiolkovsky Rocket Equation (Isp)',
|
||||||
formula: 'Δv = Isp·g₀·ln(m₀/mf)',
|
formula: 'Δv = Isp·g₀·ln(m₀/mf)',
|
||||||
category: 'Rocket Equation',
|
category: 'Rocket Equation',
|
||||||
|
description: 'The classic Tsiolkovsky rocket equation using specific impulse. Foundation of trajectory design and mission planning.',
|
||||||
variables: ['dv', 'Isp', 'g0', 'm0', 'mf'],
|
variables: ['dv', 'Isp', 'g0', 'm0', 'mf'],
|
||||||
solvers: {
|
solvers: {
|
||||||
dv: v => v.Isp * v.g0 * Math.log(v.m0 / v.mf),
|
dv: v => v.Isp * v.g0 * Math.log(v.m0 / v.mf),
|
||||||
@@ -170,6 +180,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Mass Ratio',
|
name: 'Mass Ratio',
|
||||||
formula: 'MR = m₀ / mf',
|
formula: 'MR = m₀ / mf',
|
||||||
category: 'Rocket Equation',
|
category: 'Rocket Equation',
|
||||||
|
description: 'Ratio of initial to final mass. Determines the exponential factor in the Tsiolkovsky equation.',
|
||||||
variables: ['MR', 'm0', 'mf'],
|
variables: ['MR', 'm0', 'mf'],
|
||||||
solvers: {
|
solvers: {
|
||||||
MR: v => v.m0 / v.mf,
|
MR: v => v.m0 / v.mf,
|
||||||
@@ -183,6 +194,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Propellant Mass',
|
name: 'Propellant Mass',
|
||||||
formula: 'mₚ = m₀ − mf',
|
formula: 'mₚ = m₀ − mf',
|
||||||
category: 'Rocket Equation',
|
category: 'Rocket Equation',
|
||||||
|
description: 'Mass of propellant consumed during burn. Difference between initial (wet) and final (dry) mass.',
|
||||||
variables: ['mp', 'm0', 'mf'],
|
variables: ['mp', 'm0', 'mf'],
|
||||||
solvers: {
|
solvers: {
|
||||||
mp: v => v.m0 - v.mf,
|
mp: v => v.m0 - v.mf,
|
||||||
@@ -196,6 +208,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Propellant Mass Fraction',
|
name: 'Propellant Mass Fraction',
|
||||||
formula: 'ζ = mₚ / m₀',
|
formula: 'ζ = mₚ / m₀',
|
||||||
category: 'Rocket Equation',
|
category: 'Rocket Equation',
|
||||||
|
description: 'Fraction of total mass that is propellant. Higher values indicate more efficient rocket design.',
|
||||||
variables: ['zeta', 'mp', 'm0'],
|
variables: ['zeta', 'mp', 'm0'],
|
||||||
solvers: {
|
solvers: {
|
||||||
zeta: v => v.mp / v.m0,
|
zeta: v => v.mp / v.m0,
|
||||||
@@ -209,6 +222,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Burn Time',
|
name: 'Burn Time',
|
||||||
formula: 'tᵦ = mₚ / ṁ',
|
formula: 'tᵦ = mₚ / ṁ',
|
||||||
category: 'Rocket Equation',
|
category: 'Rocket Equation',
|
||||||
|
description: 'Duration of main engine burn. Calculated from total propellant mass and mass flow rate.',
|
||||||
variables: ['tb', 'mp', 'mdot'],
|
variables: ['tb', 'mp', 'mdot'],
|
||||||
solvers: {
|
solvers: {
|
||||||
tb: v => v.mp / v.mdot,
|
tb: v => v.mp / v.mdot,
|
||||||
@@ -223,6 +237,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Isentropic Temperature Ratio',
|
name: 'Isentropic Temperature Ratio',
|
||||||
formula: 'T/T₀ = (1 + (γ−1)/2·M²)⁻¹',
|
formula: 'T/T₀ = (1 + (γ−1)/2·M²)⁻¹',
|
||||||
category: 'Isentropic Flow',
|
category: 'Isentropic Flow',
|
||||||
|
description: 'Static to stagnation temperature ratio for isentropic flow. Key relation for compressible flow analysis.',
|
||||||
variables: ['T', 'T0', 'M', 'gamma'],
|
variables: ['T', 'T0', 'M', 'gamma'],
|
||||||
solvers: {
|
solvers: {
|
||||||
T: v => v.T0 / (1 + (v.gamma - 1) / 2 * v.M * v.M),
|
T: v => v.T0 / (1 + (v.gamma - 1) / 2 * v.M * v.M),
|
||||||
@@ -241,6 +256,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Isentropic Pressure Ratio',
|
name: 'Isentropic Pressure Ratio',
|
||||||
formula: 'p/p₀ = (1 + (γ−1)/2·M²)^(−γ/(γ−1))',
|
formula: 'p/p₀ = (1 + (γ−1)/2·M²)^(−γ/(γ−1))',
|
||||||
category: 'Isentropic Flow',
|
category: 'Isentropic Flow',
|
||||||
|
description: 'Static to stagnation pressure ratio for isentropic flow. Essential for nozzle and intake analysis.',
|
||||||
variables: ['p_static', 'p0', 'M', 'gamma'],
|
variables: ['p_static', 'p0', 'M', 'gamma'],
|
||||||
solvers: {
|
solvers: {
|
||||||
p_static: v => {
|
p_static: v => {
|
||||||
@@ -265,6 +281,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Isentropic Density Ratio',
|
name: 'Isentropic Density Ratio',
|
||||||
formula: 'ρ/ρ₀ = (1 + (γ−1)/2·M²)^(−1/(γ−1))',
|
formula: 'ρ/ρ₀ = (1 + (γ−1)/2·M²)^(−1/(γ−1))',
|
||||||
category: 'Isentropic Flow',
|
category: 'Isentropic Flow',
|
||||||
|
description: 'Static to stagnation density ratio for isentropic flow. Used in continuity equations and flow analysis.',
|
||||||
variables: ['rho', 'rho0', 'M', 'gamma'],
|
variables: ['rho', 'rho0', 'M', 'gamma'],
|
||||||
solvers: {
|
solvers: {
|
||||||
rho: v => {
|
rho: v => {
|
||||||
@@ -287,6 +304,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Speed of Sound',
|
name: 'Speed of Sound',
|
||||||
formula: 'a = √(γ·R·T)',
|
formula: 'a = √(γ·R·T)',
|
||||||
category: 'Isentropic Flow',
|
category: 'Isentropic Flow',
|
||||||
|
description: 'Local speed of sound as a function of temperature and gas properties. Used to define Mach number.',
|
||||||
variables: ['a_sound', 'gamma', 'R', 'T'],
|
variables: ['a_sound', 'gamma', 'R', 'T'],
|
||||||
solvers: {
|
solvers: {
|
||||||
a_sound: v => Math.sqrt(v.gamma * v.R * v.T),
|
a_sound: v => Math.sqrt(v.gamma * v.R * v.T),
|
||||||
@@ -301,6 +319,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Flow Velocity',
|
name: 'Flow Velocity',
|
||||||
formula: 'v = M·a',
|
formula: 'v = M·a',
|
||||||
category: 'Isentropic Flow',
|
category: 'Isentropic Flow',
|
||||||
|
description: 'Flow velocity as Mach number times local speed of sound. Relates Mach number to actual velocity.',
|
||||||
variables: ['v_flow', 'M', 'a_sound'],
|
variables: ['v_flow', 'M', 'a_sound'],
|
||||||
solvers: {
|
solvers: {
|
||||||
v_flow: v => v.M * v.a_sound,
|
v_flow: v => v.M * v.a_sound,
|
||||||
@@ -315,6 +334,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Nozzle Expansion Ratio',
|
name: 'Nozzle Expansion Ratio',
|
||||||
formula: 'ε = Aₑ / Aₜ',
|
formula: 'ε = Aₑ / Aₜ',
|
||||||
category: 'Nozzle Geometry',
|
category: 'Nozzle Geometry',
|
||||||
|
description: 'Ratio of exit area to throat area. Determines the degree of expansion in the nozzle divergent section.',
|
||||||
variables: ['eps', 'Ae', 'At'],
|
variables: ['eps', 'Ae', 'At'],
|
||||||
solvers: {
|
solvers: {
|
||||||
eps: v => v.Ae / v.At,
|
eps: v => v.Ae / v.At,
|
||||||
@@ -328,6 +348,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Isentropic Area Ratio (supersonic)',
|
name: 'Isentropic Area Ratio (supersonic)',
|
||||||
formula: 'ε = (1/Mₑ)·[(2/(γ+1))·(1+(γ−1)/2·Mₑ²)]^((γ+1)/(2(γ−1)))',
|
formula: 'ε = (1/Mₑ)·[(2/(γ+1))·(1+(γ−1)/2·Mₑ²)]^((γ+1)/(2(γ−1)))',
|
||||||
category: 'Nozzle Geometry',
|
category: 'Nozzle Geometry',
|
||||||
|
description: 'Relates exit area ratio to exit Mach number for isentropic supersonic flow. Critical for nozzle design.',
|
||||||
variables: ['eps', 'Me', 'gamma'],
|
variables: ['eps', 'Me', 'gamma'],
|
||||||
solvers: {
|
solvers: {
|
||||||
eps: v => areaRatioFromMach(v.Me, v.gamma),
|
eps: v => areaRatioFromMach(v.Me, v.gamma),
|
||||||
@@ -340,6 +361,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Choked Throat Mass Flow',
|
name: 'Choked Throat Mass Flow',
|
||||||
formula: 'ṁ = Aₜ·p₀·√(γ/(R·T₀))·(2/(γ+1))^((γ+1)/(2(γ−1)))',
|
formula: 'ṁ = Aₜ·p₀·√(γ/(R·T₀))·(2/(γ+1))^((γ+1)/(2(γ−1)))',
|
||||||
category: 'Nozzle Geometry',
|
category: 'Nozzle Geometry',
|
||||||
|
description: 'Mass flow through a choked throat condition. Maximum mass flow for given stagnation conditions.',
|
||||||
variables: ['mdot', 'At', 'p0', 'gamma', 'R', 'T0'],
|
variables: ['mdot', 'At', 'p0', 'gamma', 'R', 'T0'],
|
||||||
solvers: {
|
solvers: {
|
||||||
mdot: v => {
|
mdot: v => {
|
||||||
@@ -380,6 +402,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Nozzle Exit Pressure',
|
name: 'Nozzle Exit Pressure',
|
||||||
formula: 'pₑ = p₀·(1 + (γ−1)/2·Mₑ²)^(−γ/(γ−1))',
|
formula: 'pₑ = p₀·(1 + (γ−1)/2·Mₑ²)^(−γ/(γ−1))',
|
||||||
category: 'Nozzle Geometry',
|
category: 'Nozzle Geometry',
|
||||||
|
description: 'Static pressure at the nozzle exit. Calculated from chamber pressure and exit Mach number.',
|
||||||
variables: ['pe', 'p0', 'Me', 'gamma'],
|
variables: ['pe', 'p0', 'Me', 'gamma'],
|
||||||
solvers: {
|
solvers: {
|
||||||
pe: v => {
|
pe: v => {
|
||||||
@@ -402,6 +425,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Nozzle Exit Temperature',
|
name: 'Nozzle Exit Temperature',
|
||||||
formula: 'Tₑ = T₀ / (1 + (γ−1)/2·Mₑ²)',
|
formula: 'Tₑ = T₀ / (1 + (γ−1)/2·Mₑ²)',
|
||||||
category: 'Nozzle Geometry',
|
category: 'Nozzle Geometry',
|
||||||
|
description: 'Static temperature at the nozzle exit. Lower than stagnation temperature due to expansion.',
|
||||||
variables: ['Te', 'T0', 'Me', 'gamma'],
|
variables: ['Te', 'T0', 'Me', 'gamma'],
|
||||||
solvers: {
|
solvers: {
|
||||||
Te: v => v.T0 / (1 + (v.gamma - 1) / 2 * v.Me * v.Me),
|
Te: v => v.T0 / (1 + (v.gamma - 1) / 2 * v.Me * v.Me),
|
||||||
@@ -416,6 +440,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Nozzle Exit Velocity',
|
name: 'Nozzle Exit Velocity',
|
||||||
formula: 'Vₑ = Mₑ·√(γ·R·Tₑ)',
|
formula: 'Vₑ = Mₑ·√(γ·R·Tₑ)',
|
||||||
category: 'Nozzle Geometry',
|
category: 'Nozzle Geometry',
|
||||||
|
description: 'Actual exhaust velocity at nozzle exit. Product of exit Mach number and local speed of sound.',
|
||||||
variables: ['Ve', 'Me', 'gamma', 'R', 'Te'],
|
variables: ['Ve', 'Me', 'gamma', 'R', 'Te'],
|
||||||
solvers: {
|
solvers: {
|
||||||
Ve: v => v.Me * Math.sqrt(v.gamma * v.R * v.Te),
|
Ve: v => v.Me * Math.sqrt(v.gamma * v.R * v.Te),
|
||||||
@@ -432,6 +457,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Thrust-to-Weight Ratio',
|
name: 'Thrust-to-Weight Ratio',
|
||||||
formula: 'TWR = F / (mᵥ·g₀)',
|
formula: 'TWR = F / (mᵥ·g₀)',
|
||||||
category: 'Performance',
|
category: 'Performance',
|
||||||
|
description: 'Ratio of thrust to vehicle weight. Values > 1 allow acceleration and ascent.',
|
||||||
variables: ['TWR', 'F', 'm_vehicle', 'g0'],
|
variables: ['TWR', 'F', 'm_vehicle', 'g0'],
|
||||||
solvers: {
|
solvers: {
|
||||||
TWR: v => v.F / (v.m_vehicle * v.g0),
|
TWR: v => v.F / (v.m_vehicle * v.g0),
|
||||||
@@ -446,6 +472,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Total Impulse',
|
name: 'Total Impulse',
|
||||||
formula: 'J = F·tᵦ',
|
formula: 'J = F·tᵦ',
|
||||||
category: 'Performance',
|
category: 'Performance',
|
||||||
|
description: 'Total impulse as integrated thrust over burn time. Used for classification and performance assessment.',
|
||||||
variables: ['J', 'F', 'tb'],
|
variables: ['J', 'F', 'tb'],
|
||||||
solvers: {
|
solvers: {
|
||||||
J: v => v.F * v.tb,
|
J: v => v.F * v.tb,
|
||||||
@@ -459,6 +486,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Isp from Total Impulse',
|
name: 'Isp from Total Impulse',
|
||||||
formula: 'Isp = J / (mₚ·g₀)',
|
formula: 'Isp = J / (mₚ·g₀)',
|
||||||
category: 'Performance',
|
category: 'Performance',
|
||||||
|
description: 'Calculates specific impulse from total impulse and propellant mass. Alternative method for performance analysis.',
|
||||||
variables: ['Isp', 'J', 'mp', 'g0'],
|
variables: ['Isp', 'J', 'mp', 'g0'],
|
||||||
solvers: {
|
solvers: {
|
||||||
Isp: v => v.J / (v.mp * v.g0),
|
Isp: v => v.J / (v.mp * v.g0),
|
||||||
@@ -474,6 +502,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Oxidiser/Fuel Ratio',
|
name: 'Oxidiser/Fuel Ratio',
|
||||||
formula: 'O/F = ṁₒₓ / ṁf',
|
formula: 'O/F = ṁₒₓ / ṁf',
|
||||||
category: 'Propellant',
|
category: 'Propellant',
|
||||||
|
description: 'Mass flow ratio of oxidiser to fuel. Optimum value depends on propellant pair and design parameters.',
|
||||||
variables: ['OF', 'mdot_ox', 'mdot_f'],
|
variables: ['OF', 'mdot_ox', 'mdot_f'],
|
||||||
solvers: {
|
solvers: {
|
||||||
OF: v => v.mdot_ox / v.mdot_f,
|
OF: v => v.mdot_ox / v.mdot_f,
|
||||||
@@ -487,6 +516,7 @@ export const EQUATIONS = [
|
|||||||
name: 'Total Mass Flow',
|
name: 'Total Mass Flow',
|
||||||
formula: 'ṁ = ṁₒₓ + ṁf',
|
formula: 'ṁ = ṁₒₓ + ṁf',
|
||||||
category: 'Propellant',
|
category: 'Propellant',
|
||||||
|
description: 'Total propellant mass flow is sum of oxidiser and fuel mass flows. Used in bipropellant engines.',
|
||||||
variables: ['mdot', 'mdot_ox', 'mdot_f'],
|
variables: ['mdot', 'mdot_ox', 'mdot_f'],
|
||||||
solvers: {
|
solvers: {
|
||||||
mdot: v => v.mdot_ox + v.mdot_f,
|
mdot: v => v.mdot_ox + v.mdot_f,
|
||||||
@@ -500,12 +530,70 @@ export const EQUATIONS = [
|
|||||||
name: 'Mass Flow Split from O/F',
|
name: 'Mass Flow Split from O/F',
|
||||||
formula: 'ṁ_f = ṁ/(1+OF), ṁ_ox = ṁ·OF/(1+OF)',
|
formula: 'ṁ_f = ṁ/(1+OF), ṁ_ox = ṁ·OF/(1+OF)',
|
||||||
category: 'Propellant',
|
category: 'Propellant',
|
||||||
|
description: 'Splits total mass flow into oxidiser and fuel flows given their mass ratio. Used for injection design.',
|
||||||
variables: ['mdot', 'OF', 'mdot_f', 'mdot_ox'],
|
variables: ['mdot', 'OF', 'mdot_f', 'mdot_ox'],
|
||||||
solvers: {
|
solvers: {
|
||||||
mdot_f: Object.assign(v => v.mdot / (1 + v.OF), { requires: () => ['mdot', 'OF'] }),
|
mdot_f: Object.assign(v => v.mdot / (1 + v.OF), { requires: () => ['mdot', 'OF'] }),
|
||||||
mdot_ox: Object.assign(v => v.mdot * v.OF / (1 + v.OF), { requires: () => ['mdot', 'OF'] }),
|
mdot_ox: Object.assign(v => v.mdot * v.OF / (1 + v.OF), { requires: () => ['mdot', 'OF'] }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Chamber Characteristic Length ──────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'chamber_characteristic_length',
|
||||||
|
name: 'Chamber Characteristic Length',
|
||||||
|
formula: 'L* = Vᶜ / Aₜ',
|
||||||
|
category: 'Nozzle Performance',
|
||||||
|
description: 'Characteristic chamber length relates chamber volume to throat area. Typical range 0.5–1.5 m for liquid rockets.',
|
||||||
|
variables: ['Lstar', 'Vc', 'At'],
|
||||||
|
solvers: {
|
||||||
|
Lstar: v => v.Vc / v.At,
|
||||||
|
Vc: v => v.Lstar * v.At,
|
||||||
|
At: v => v.Vc / v.Lstar,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Specific Gas Constant from Molar Mass ─────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'specific_gas_constant',
|
||||||
|
name: 'Specific Gas Constant',
|
||||||
|
formula: 'R = R̄ / Mₘ',
|
||||||
|
category: 'Isentropic Flow',
|
||||||
|
description: 'Specific gas constant for a particular gas, derived from the universal gas constant and molar mass. R̄ = 8.314 J/(mol·K).',
|
||||||
|
variables: ['R', 'Mm'],
|
||||||
|
solvers: {
|
||||||
|
R: v => 8.314 / v.Mm,
|
||||||
|
Mm: v => 8.314 / v.R,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Vehicle Mass Identity ─────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'vehicle_mass_identity',
|
||||||
|
name: 'Vehicle Mass (Wet Mass)',
|
||||||
|
formula: 'mᵥ = m₀',
|
||||||
|
category: 'Performance',
|
||||||
|
description: 'Identity relation: vehicle mass equals initial (wet) mass for TWR calculation.',
|
||||||
|
variables: ['m_vehicle', 'm0'],
|
||||||
|
solvers: {
|
||||||
|
m_vehicle: v => v.m0,
|
||||||
|
m0: v => v.m_vehicle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Subsonic Area Ratio ───────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'area_ratio_mach_subsonic',
|
||||||
|
name: 'Isentropic Area Ratio (subsonic)',
|
||||||
|
formula: 'ε = (1/Mₑ)·[(2/(γ+1))·(1+(γ−1)/2·Mₑ²)]^((γ+1)/(2(γ−1)))',
|
||||||
|
category: 'Nozzle Geometry',
|
||||||
|
description: 'Isentropic area ratio as a function of Mach number (subsonic branch). Used for intake and subsonic flow analysis.',
|
||||||
|
variables: ['eps', 'Me', 'gamma'],
|
||||||
|
solvers: {
|
||||||
|
eps: v => areaRatioFromMach(v.Me, v.gamma),
|
||||||
|
Me: v => machFromAreaRatio(v.eps, v.gamma, false),
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// Equation presets: named groups that seed the workspace with a useful set of variables
|
// Equation presets: named groups that seed the workspace with a useful set of variables
|
||||||
|
|||||||
@@ -115,6 +115,12 @@ export const UNIT_FAMILIES = {
|
|||||||
{ id: '—', label: '—', toSI: v => v, fromSI: v => v },
|
{ id: '—', label: '—', toSI: v => v, fromSI: v => v },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
molar_mass: {
|
||||||
|
units: [
|
||||||
|
{ id: 'g/mol', label: 'g/mol', toSI: v => v * 0.001, fromSI: v => v * 1000 },
|
||||||
|
{ id: 'kg/mol', label: 'kg/mol', toSI: v => v, fromSI: v => v },
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUnitsForFamily(familyId) {
|
export function getUnitsForFamily(familyId) {
|
||||||
|
|||||||
@@ -92,6 +92,20 @@ export const VARIABLES = {
|
|||||||
unitFamily: 'area',
|
unitFamily: 'area',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
Lstar: {
|
||||||
|
id: 'Lstar', symbol: 'L*', name: 'Characteristic Chamber Length', units: 'm',
|
||||||
|
description: 'Characteristic length of the combustion chamber: L* = Vᶜ/Aₜ. Higher values indicate larger chambers.',
|
||||||
|
category: 'Nozzle Performance',
|
||||||
|
unitFamily: 'length',
|
||||||
|
},
|
||||||
|
|
||||||
|
Vc: {
|
||||||
|
id: 'Vc', symbol: 'Vᶜ', name: 'Chamber Volume', units: 'm³',
|
||||||
|
description: 'Total volume of the combustion chamber',
|
||||||
|
category: 'Nozzle Performance',
|
||||||
|
unitFamily: 'volume',
|
||||||
|
},
|
||||||
|
|
||||||
// ── Tsiolkovsky Rocket Equation ───────────────────────────────────────
|
// ── Tsiolkovsky Rocket Equation ───────────────────────────────────────
|
||||||
dv: {
|
dv: {
|
||||||
id: 'dv', symbol: 'Δv', name: 'Delta-v', units: 'm/s',
|
id: 'dv', symbol: 'Δv', name: 'Delta-v', units: 'm/s',
|
||||||
@@ -155,6 +169,13 @@ export const VARIABLES = {
|
|||||||
category: 'Isentropic Flow',
|
category: 'Isentropic Flow',
|
||||||
unitFamily: 'spec_gas',
|
unitFamily: 'spec_gas',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
Mm: {
|
||||||
|
id: 'Mm', symbol: 'Mₘ', name: 'Molar Mass', units: 'kg/mol',
|
||||||
|
description: 'Molar mass of the exhaust gas mixture. Used to calculate specific gas constant: R = R̄/Mₘ',
|
||||||
|
category: 'Isentropic Flow',
|
||||||
|
unitFamily: 'molar_mass',
|
||||||
|
},
|
||||||
T: {
|
T: {
|
||||||
id: 'T', symbol: 'T', name: 'Static Temperature', units: 'K',
|
id: 'T', symbol: 'T', name: 'Static Temperature', units: 'K',
|
||||||
description: 'Local static temperature at a cross-section',
|
description: 'Local static temperature at a cross-section',
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function useEngineDesign() {
|
|||||||
const [chamber, setChamber] = useState({ Lstar: 1.0, contractionRatio: 8, convAngleDeg: 30 })
|
const [chamber, setChamber] = useState({ Lstar: 1.0, contractionRatio: 8, convAngleDeg: 30 })
|
||||||
const [nozzle, setNozzle] = useState({ type: 'conical', divAngleDeg: 15 })
|
const [nozzle, setNozzle] = useState({ type: 'conical', divAngleDeg: 15 })
|
||||||
const [injector, setInjector] = useState({ type: 'doublet', N: 20, dpFraction: 0.2, Cd: 0.7, rhoFuel: 800, rhoOx: 1140 })
|
const [injector, setInjector] = useState({ type: 'doublet', N: 20, dpFraction: 0.2, Cd: 0.7, rhoFuel: 800, rhoOx: 1140 })
|
||||||
const [cooling, setCooling] = useState({ method: 'regenerative', channelCount: 40, filmFraction: 0.05 })
|
const [cooling, setCooling] = useState({ method: 'regenerative', channelCount: 60, mu: 6e-5, cp: 2000, Pr: 0.7, T_wall: 800, filmFraction: 0.05 })
|
||||||
const [feedSystem, setFeedSystem] = useState({
|
const [feedSystem, setFeedSystem] = useState({
|
||||||
type: 'pressure_fed', feedFactor: 1.3,
|
type: 'pressure_fed', feedFactor: 1.3,
|
||||||
rhoFuel: 800, rhoOx: 1140, pressurantR: 2077, pressurantT: 300,
|
rhoFuel: 800, rhoOx: 1140, pressurantR: 2077, pressurantT: 300,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '../engine/engineExportImport.js'
|
} from '../engine/engineExportImport.js'
|
||||||
import DesignSection from '../components/engine/DesignSection.jsx'
|
import DesignSection from '../components/engine/DesignSection.jsx'
|
||||||
import EngineModel3D from '../components/engine/EngineModel3D.jsx'
|
import EngineModel3D from '../components/engine/EngineModel3D.jsx'
|
||||||
|
import ErrorBoundary from '../components/ErrorBoundary.jsx'
|
||||||
import { formatValue } from '../engine/format.js'
|
import { formatValue } from '../engine/format.js'
|
||||||
import { getUnitsForFamily } from '../engine/units.js'
|
import { getUnitsForFamily } from '../engine/units.js'
|
||||||
import { ENGINE_FIELD_INFO } from '../engine/engineFieldInfo.js'
|
import { ENGINE_FIELD_INFO } from '../engine/engineFieldInfo.js'
|
||||||
@@ -524,13 +525,48 @@ export default function EnginePage() {
|
|||||||
infoKey="coolingMethod"
|
infoKey="coolingMethod"
|
||||||
/>
|
/>
|
||||||
{cooling.method === 'regenerative' && (
|
{cooling.method === 'regenerative' && (
|
||||||
<NumInput
|
<>
|
||||||
label="Channel Count"
|
<NumInput
|
||||||
value={cooling.channelCount}
|
label="Channel Count"
|
||||||
onChange={v => setCooling(c => ({ ...c, channelCount: Math.max(1, Math.round(v)) }))}
|
value={cooling.channelCount}
|
||||||
infoKey="channelCount"
|
onChange={v => setCooling(c => ({ ...c, channelCount: Math.max(1, Math.round(v)) }))}
|
||||||
step="1"
|
infoKey="channelCount"
|
||||||
/>
|
step="1"
|
||||||
|
/>
|
||||||
|
<NumInput
|
||||||
|
label="Dynamic Viscosity (μ)"
|
||||||
|
value={cooling.mu}
|
||||||
|
onChange={v => setCooling(c => ({ ...c, mu: v }))}
|
||||||
|
units="Pa·s"
|
||||||
|
step="1e-5"
|
||||||
|
placeholder="6e-5"
|
||||||
|
/>
|
||||||
|
<NumInput
|
||||||
|
label="Specific Heat (cₚ)"
|
||||||
|
value={cooling.cp}
|
||||||
|
onChange={v => setCooling(c => ({ ...c, cp: v }))}
|
||||||
|
units="J/(kg·K)"
|
||||||
|
step="100"
|
||||||
|
placeholder="2000"
|
||||||
|
/>
|
||||||
|
<NumInput
|
||||||
|
label="Prandtl Number (Pr)"
|
||||||
|
value={cooling.Pr}
|
||||||
|
onChange={v => setCooling(c => ({ ...c, Pr: v }))}
|
||||||
|
units="—"
|
||||||
|
step="0.05"
|
||||||
|
placeholder="0.7"
|
||||||
|
/>
|
||||||
|
<NumInput
|
||||||
|
label="Wall Temperature (T_wall)"
|
||||||
|
value={cooling.T_wall}
|
||||||
|
onChange={v => setCooling(c => ({ ...c, T_wall: v }))}
|
||||||
|
unitFamily="temperature"
|
||||||
|
defaultUnitId="K"
|
||||||
|
step="50"
|
||||||
|
placeholder="800"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{cooling.method === 'film' && (
|
{cooling.method === 'film' && (
|
||||||
<NumInput
|
<NumInput
|
||||||
@@ -593,7 +629,9 @@ export default function EnginePage() {
|
|||||||
|
|
||||||
{/* ── Centre: 3D Model ── */}
|
{/* ── Centre: 3D Model ── */}
|
||||||
<div className="flex-1 relative border-r border-slate-700 bg-slate-950/50">
|
<div className="flex-1 relative border-r border-slate-700 bg-slate-950/50">
|
||||||
<EngineModel3D chamberGeometry={cg} nozzleGeometry={ng} />
|
<ErrorBoundary>
|
||||||
|
<EngineModel3D chamberGeometry={cg} nozzleGeometry={ng} />
|
||||||
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Right: Results ── */}
|
{/* ── Right: Results ── */}
|
||||||
@@ -647,7 +685,7 @@ export default function EnginePage() {
|
|||||||
<ResultRow label="Est. Heat Flux" value={cr.q_est} unit="W/m²" infoKey="q_est_result" />
|
<ResultRow label="Est. Heat Flux" value={cr.q_est} unit="W/m²" infoKey="q_est_result" />
|
||||||
<ResultRow label="Total Heat Load" value={cr.q_total} unit="W" infoKey="q_total_result" />
|
<ResultRow label="Total Heat Load" value={cr.q_total} unit="W" infoKey="q_total_result" />
|
||||||
<ResultRow label="Channel Count" value={cr.channelCount} unit="—" infoKey="channelCount_result" />
|
<ResultRow label="Channel Count" value={cr.channelCount} unit="—" infoKey="channelCount_result" />
|
||||||
<ResultRow label="Channel Area (each)" value={cr.channelArea} unitFamily="area" defaultUnitId="mm²" infoKey="channelArea_result" />
|
<ResultRow label="Heat load / channel" value={cr.q_perChannel} unit="W" infoKey="q_perChannel_result" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{cr.method === 'film' && (
|
{cr.method === 'film' && (
|
||||||
|
|||||||
@@ -23,9 +23,12 @@ export default function Home() {
|
|||||||
<h2 className="text-xl font-semibold text-white mb-2 group-hover:text-blue-400 transition-colors">
|
<h2 className="text-xl font-semibold text-white mb-2 group-hover:text-blue-400 transition-colors">
|
||||||
Equation Solver
|
Equation Solver
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-slate-400">
|
<p className="text-sm text-slate-400 mb-3">
|
||||||
Drag-and-drop rocketry variables. Enter known values and unknowns solve automatically using constraint propagation.
|
Drag-and-drop rocketry variables. Enter known values and unknowns solve automatically using constraint propagation.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 mb-3">
|
||||||
|
Includes: thrust, Isp, c*, thrust coefficient, Tsiolkovsky, mass/burn-time, isentropic flow, nozzle geometry, exit conditions, TWR, total impulse, O/F ratio
|
||||||
|
</p>
|
||||||
<div className="mt-4 text-sm text-blue-400 font-medium">Open solver →</div>
|
<div className="mt-4 text-sm text-blue-400 font-medium">Open solver →</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -43,14 +46,33 @@ export default function Home() {
|
|||||||
<div className="mt-4 text-sm text-blue-400 font-medium">Open designer →</div>
|
<div className="mt-4 text-sm text-blue-400 font-medium">Open designer →</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="p-6 rounded-xl border border-slate-800 bg-slate-900/50 opacity-60 cursor-not-allowed">
|
<Link
|
||||||
<div className="text-3xl mb-3">📊</div>
|
to="/design/rocket"
|
||||||
<h2 className="text-xl font-semibold text-slate-500 mb-2">Trajectory Plotter</h2>
|
className="group block p-6 rounded-xl border border-slate-700 bg-slate-900 hover:border-blue-500 hover:bg-slate-800 transition-colors"
|
||||||
<p className="text-sm text-slate-500">
|
>
|
||||||
Simulate flight trajectories with drag and gravity losses.
|
<div className="text-3xl mb-3">🛸</div>
|
||||||
|
<h2 className="text-xl font-semibold text-white mb-2 group-hover:text-blue-400 transition-colors">
|
||||||
|
Rocket Designer
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Design and configure multi-stage rockets. Visualize your design in 3D with mass budget and delta-v calculations.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 text-sm text-slate-600 font-medium">Coming soon</div>
|
<div className="mt-4 text-sm text-blue-400 font-medium">Open designer →</div>
|
||||||
</div>
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/knowledgebase/fuels"
|
||||||
|
className="group block p-6 rounded-xl border border-slate-700 bg-slate-900 hover:border-blue-500 hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="text-3xl mb-3">📚</div>
|
||||||
|
<h2 className="text-xl font-semibold text-white mb-2 group-hover:text-blue-400 transition-colors">
|
||||||
|
Fuels & Oxidisers
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Browse a comprehensive database of rocket propellants with chemical properties, performance data, and specifications.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 text-sm text-blue-400 font-medium">View database →</div>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
225
src/pages/KnowledgebaseEquationsPage.jsx
Normal file
225
src/pages/KnowledgebaseEquationsPage.jsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { useState, useMemo, useRef } from 'react'
|
||||||
|
import React from 'react'
|
||||||
|
import { EQUATIONS } from '../engine/equations.js'
|
||||||
|
import { VARIABLES } from '../engine/variables.js'
|
||||||
|
|
||||||
|
export default function KnowledgebaseEquationsPage() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState('All')
|
||||||
|
const [activeVariableFilter, setActiveVariableFilter] = useState([])
|
||||||
|
const equationRefs = useRef({})
|
||||||
|
|
||||||
|
// Get all unique categories
|
||||||
|
const categories = ['All', ...new Set(EQUATIONS.map(eq => eq.category))]
|
||||||
|
|
||||||
|
// Apply all three filters
|
||||||
|
const filteredEquations = useMemo(() => {
|
||||||
|
const q = search.toLowerCase().trim()
|
||||||
|
|
||||||
|
return EQUATIONS.filter(eq => {
|
||||||
|
// Category filter
|
||||||
|
if (categoryFilter !== 'All' && eq.category !== categoryFilter) return false
|
||||||
|
|
||||||
|
// Text search
|
||||||
|
if (q) {
|
||||||
|
const nameMatch = eq.name.toLowerCase().includes(q)
|
||||||
|
const formulaMatch = eq.formula.toLowerCase().includes(q)
|
||||||
|
const descMatch = eq.description.toLowerCase().includes(q)
|
||||||
|
const varMatch = eq.variables.some(varId => {
|
||||||
|
const v = VARIABLES[varId]
|
||||||
|
return v?.name.toLowerCase().includes(q) || v?.symbol?.toLowerCase().includes(q)
|
||||||
|
})
|
||||||
|
if (!nameMatch && !formulaMatch && !descMatch && !varMatch) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variable filter — equation must have ALL active variables
|
||||||
|
if (activeVariableFilter.length > 0) {
|
||||||
|
const hasAllVars = activeVariableFilter.every(varId => eq.variables.includes(varId))
|
||||||
|
if (!hasAllVars) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [search, categoryFilter, activeVariableFilter])
|
||||||
|
|
||||||
|
// Toggle variable in active filter
|
||||||
|
const toggleVariableFilter = (varId) => {
|
||||||
|
setActiveVariableFilter(prev =>
|
||||||
|
prev.includes(varId)
|
||||||
|
? prev.filter(v => v !== varId)
|
||||||
|
: [...prev, varId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove variable from filter
|
||||||
|
const removeVariableFilter = (varId) => {
|
||||||
|
setActiveVariableFilter(prev => prev.filter(v => v !== varId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to equation
|
||||||
|
const scrollToEquation = (eqId) => {
|
||||||
|
equationRefs.current[eqId]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* ── Left panel ────────────────────────────────────────────────────── */}
|
||||||
|
<div className="w-72 shrink-0 flex flex-col border-r border-slate-700 bg-slate-900">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="p-3 border-b border-slate-700">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search equations…"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="w-full px-3 py-1.5 rounded-md bg-slate-800 border border-slate-600 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category filter pills */}
|
||||||
|
<div className="px-3 pt-2 pb-2 border-b border-slate-700 flex flex-wrap gap-1">
|
||||||
|
{categories.map(cat => (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
onClick={() => setCategoryFilter(cat)}
|
||||||
|
className={`px-2 py-0.5 rounded text-xs font-medium transition-colors ${
|
||||||
|
categoryFilter === cat
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Equation list */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{filteredEquations.length === 0 && (
|
||||||
|
<p className="px-4 py-6 text-sm text-slate-500 text-center">No equations match your filters.</p>
|
||||||
|
)}
|
||||||
|
{filteredEquations.map(eq => (
|
||||||
|
<button
|
||||||
|
key={eq.id}
|
||||||
|
onClick={() => scrollToEquation(eq.id)}
|
||||||
|
className="w-full text-left px-3 py-2 border-b border-slate-800 text-sm hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="font-medium text-slate-100 truncate">{eq.name}</div>
|
||||||
|
<div className="text-xs text-slate-500 mt-0.5 truncate">{eq.category}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right panel ───────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden bg-slate-950">
|
||||||
|
{/* Active variable filter strip */}
|
||||||
|
{activeVariableFilter.length > 0 && (
|
||||||
|
<div className="px-4 py-3 border-b border-slate-700 bg-slate-900/50 flex items-center gap-3 flex-wrap">
|
||||||
|
<span className="text-xs text-slate-400">Active filters:</span>
|
||||||
|
{activeVariableFilter.map(varId => {
|
||||||
|
const v = VARIABLES[varId]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={varId}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-blue-900/30 border border-blue-700/50 text-xs text-blue-200"
|
||||||
|
>
|
||||||
|
<span>{v?.symbol} · {v?.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeVariableFilter(varId)}
|
||||||
|
className="ml-1 text-blue-400 hover:text-blue-200 transition-colors text-sm leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveVariableFilter([])}
|
||||||
|
className="text-xs text-slate-400 hover:text-slate-200 transition-colors ml-auto px-2 py-1 rounded hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Equations grid */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
<div className="space-y-4 max-w-5xl">
|
||||||
|
{filteredEquations.length === 0 ? (
|
||||||
|
<div className="text-center text-slate-500 py-12 text-sm">
|
||||||
|
No equations match your filters.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredEquations.map(eq => (
|
||||||
|
<EquationCard
|
||||||
|
key={eq.id}
|
||||||
|
eq={eq}
|
||||||
|
activeVariableFilter={activeVariableFilter}
|
||||||
|
onToggleVariable={toggleVariableFilter}
|
||||||
|
ref={el => equationRefs.current[eq.id] = el}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const EquationCard = React.forwardRef(function EquationCard({ eq, activeVariableFilter, onToggleVariable }, ref) {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="rounded-xl border border-slate-700 bg-slate-900 overflow-hidden">
|
||||||
|
{/* Card header */}
|
||||||
|
<div className="px-4 pt-3 pb-2 flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-semibold text-slate-100 text-sm">{eq.name}</span>
|
||||||
|
<span className="text-[10px] font-medium text-slate-400 bg-slate-800 border border-slate-700 px-1.5 py-0.5 rounded">
|
||||||
|
{eq.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formula */}
|
||||||
|
<div className="mx-4 mb-3 rounded-lg bg-slate-950 border border-slate-700 px-4 py-2.5">
|
||||||
|
<div className="font-mono text-sm text-amber-300 text-center leading-relaxed">
|
||||||
|
{eq.formula}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="px-4 mb-3">
|
||||||
|
<p className="text-slate-300 text-sm">{eq.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Variable chips */}
|
||||||
|
<div className="px-4 pb-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{eq.variables.map(varId => {
|
||||||
|
const v = VARIABLES[varId]
|
||||||
|
const isActive = activeVariableFilter.includes(varId)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={varId}
|
||||||
|
onClick={() => onToggleVariable(varId)}
|
||||||
|
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors border ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-900/50 border-blue-600 text-blue-100'
|
||||||
|
: 'bg-slate-800 border-slate-600 text-slate-300 hover:border-blue-500 hover:text-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-mono font-bold">{v?.symbol}</span>
|
||||||
|
{' · '}
|
||||||
|
<span>{v?.name}</span>
|
||||||
|
{v?.units && <span className="text-slate-500"> ({v.units})</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -3,18 +3,30 @@ import { PropellantModal } from '../components/PropellantModal.jsx'
|
|||||||
import { useRocketDesign } from '../hooks/useRocketDesign.js'
|
import { useRocketDesign } from '../hooks/useRocketDesign.js'
|
||||||
import DesignSection from '../components/engine/DesignSection.jsx'
|
import DesignSection from '../components/engine/DesignSection.jsx'
|
||||||
import RocketModel3D from '../components/rocket/RocketModel3D.jsx'
|
import RocketModel3D from '../components/rocket/RocketModel3D.jsx'
|
||||||
|
import ErrorBoundary from '../components/ErrorBoundary.jsx'
|
||||||
import { formatValue } from '../engine/format.js'
|
import { formatValue } from '../engine/format.js'
|
||||||
|
import { getUnitsForFamily } from '../engine/units.js'
|
||||||
import { exportRocketJSON, parseRocketImport, downloadBlob } from '../engine/rocketExportImport.js'
|
import { exportRocketJSON, parseRocketImport, downloadBlob } from '../engine/rocketExportImport.js'
|
||||||
|
|
||||||
/* ── Tiny input / result primitives (self-contained, no unit dropdown) ── */
|
/* ── Tiny input / result primitives (self-contained, no unit dropdown) ── */
|
||||||
|
|
||||||
function NumInput({ label, value, onChange, units, step, placeholder }) {
|
function NumInput({ label, value, onChange, units, step, placeholder, unitFamily, defaultUnitId }) {
|
||||||
const display = value == null ? '' : value
|
const unitList = unitFamily ? getUnitsForFamily(unitFamily) : null
|
||||||
|
const [selectedUnitId, setSelectedUnitId] = useState(
|
||||||
|
() => defaultUnitId ?? unitList?.[0]?.id ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedUnit = unitList?.find(u => u.id === selectedUnitId) ?? unitList?.[0] ?? null
|
||||||
|
|
||||||
|
const displayValue = value == null
|
||||||
|
? ''
|
||||||
|
: selectedUnit ? selectedUnit.fromSI(value) : value
|
||||||
|
|
||||||
function handleChange(str) {
|
function handleChange(str) {
|
||||||
if (str === '') { onChange(null); return }
|
if (str === '') { onChange(null); return }
|
||||||
const n = parseFloat(str)
|
const typed = parseFloat(str)
|
||||||
if (!isNaN(n)) onChange(n)
|
if (isNaN(typed)) return
|
||||||
|
onChange(selectedUnit ? selectedUnit.toSI(typed) : typed)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -22,14 +34,27 @@ function NumInput({ label, value, onChange, units, step, placeholder }) {
|
|||||||
<label className="text-xs text-slate-400 w-44 shrink-0">{label}</label>
|
<label className="text-xs text-slate-400 w-44 shrink-0">{label}</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={display}
|
value={displayValue}
|
||||||
step={step ?? 'any'}
|
step={step ?? 'any'}
|
||||||
placeholder={placeholder ?? ''}
|
placeholder={placeholder ?? ''}
|
||||||
onChange={e => handleChange(e.target.value)}
|
onChange={e => handleChange(e.target.value)}
|
||||||
className="flex-1 min-w-0 w-24 px-2 py-1 bg-slate-800 border border-slate-600 rounded text-sm text-slate-100
|
className="flex-1 min-w-0 w-24 px-2 py-1 bg-slate-800 border border-slate-600 rounded text-sm text-slate-100
|
||||||
focus:border-blue-500 focus:outline-none placeholder-slate-600"
|
focus:border-blue-500 focus:outline-none placeholder-slate-600"
|
||||||
/>
|
/>
|
||||||
{units && <span className="text-xs text-slate-500 shrink-0 whitespace-nowrap">{units}</span>}
|
{unitList && unitList.length > 1 ? (
|
||||||
|
<select
|
||||||
|
value={selectedUnit?.id ?? ''}
|
||||||
|
onChange={e => setSelectedUnitId(e.target.value)}
|
||||||
|
className="px-1 py-0.5 bg-slate-700 border border-slate-600 rounded text-xs text-slate-300
|
||||||
|
focus:outline-none cursor-pointer shrink-0"
|
||||||
|
>
|
||||||
|
{unitList.map(u => <option key={u.id} value={u.id}>{u.label}</option>)}
|
||||||
|
</select>
|
||||||
|
) : unitList?.[0] ? (
|
||||||
|
<span className="text-xs text-slate-500 shrink-0">{unitList[0].label}</span>
|
||||||
|
) : units ? (
|
||||||
|
<span className="text-xs text-slate-500 shrink-0 whitespace-nowrap">{units}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -235,10 +260,11 @@ export default function RocketPage() {
|
|||||||
<DesignSection title="Vehicle Geometry">
|
<DesignSection title="Vehicle Geometry">
|
||||||
<NumInput
|
<NumInput
|
||||||
label="Outer Diameter"
|
label="Outer Diameter"
|
||||||
value={displayRadius != null ? parseFloat((displayRadius * 2000).toPrecision(6)) : null}
|
value={displayRadius != null ? displayRadius * 2 : null}
|
||||||
onChange={v => setOuterRadius(v != null ? v / 2000 : null)}
|
onChange={v => setOuterRadius(v != null ? v / 2 : null)}
|
||||||
units="mm"
|
unitFamily="length"
|
||||||
step="10"
|
defaultUnitId="mm"
|
||||||
|
step="0.01"
|
||||||
placeholder={suggestedRadius ? `${(suggestedRadius * 2000).toFixed(0)}` : 'e.g. 300'}
|
placeholder={suggestedRadius ? `${(suggestedRadius * 2000).toFixed(0)}` : 'e.g. 300'}
|
||||||
/>
|
/>
|
||||||
{suggestedRadius && !outerRadius && (
|
{suggestedRadius && !outerRadius && (
|
||||||
@@ -292,7 +318,8 @@ export default function RocketPage() {
|
|||||||
label="Fuel Density (ρ_f)"
|
label="Fuel Density (ρ_f)"
|
||||||
value={propDensities.rhoFuel}
|
value={propDensities.rhoFuel}
|
||||||
onChange={v => setPropDensities(p => ({ ...p, rhoFuel: v ?? 800 }))}
|
onChange={v => setPropDensities(p => ({ ...p, rhoFuel: v ?? 800 }))}
|
||||||
units="kg/m³"
|
unitFamily="density"
|
||||||
|
defaultUnitId="kg/m³"
|
||||||
step="10"
|
step="10"
|
||||||
placeholder="800"
|
placeholder="800"
|
||||||
/>
|
/>
|
||||||
@@ -300,7 +327,8 @@ export default function RocketPage() {
|
|||||||
label="Oxidizer Density (ρ_ox)"
|
label="Oxidizer Density (ρ_ox)"
|
||||||
value={propDensities.rhoOx}
|
value={propDensities.rhoOx}
|
||||||
onChange={v => setPropDensities(p => ({ ...p, rhoOx: v ?? 1140 }))}
|
onChange={v => setPropDensities(p => ({ ...p, rhoOx: v ?? 1140 }))}
|
||||||
units="kg/m³"
|
unitFamily="density"
|
||||||
|
defaultUnitId="kg/m³"
|
||||||
step="10"
|
step="10"
|
||||||
placeholder="1140"
|
placeholder="1140"
|
||||||
/>
|
/>
|
||||||
@@ -308,8 +336,9 @@ export default function RocketPage() {
|
|||||||
label="Burn Time"
|
label="Burn Time"
|
||||||
value={structure.burnTime}
|
value={structure.burnTime}
|
||||||
onChange={v => setStructure(s => ({ ...s, burnTime: v }))}
|
onChange={v => setStructure(s => ({ ...s, burnTime: v }))}
|
||||||
units="s"
|
unitFamily="time"
|
||||||
step="1"
|
defaultUnitId="s"
|
||||||
|
step="0.1"
|
||||||
placeholder={engineData?.inputs?.burnTime ?? '30'}
|
placeholder={engineData?.inputs?.burnTime ?? '30'}
|
||||||
/>
|
/>
|
||||||
{effectiveBurnTime != null && !structure.burnTime && (
|
{effectiveBurnTime != null && !structure.burnTime && (
|
||||||
@@ -324,16 +353,18 @@ export default function RocketPage() {
|
|||||||
label="Payload Mass"
|
label="Payload Mass"
|
||||||
value={payload.mass}
|
value={payload.mass}
|
||||||
onChange={v => setPayload(p => ({ ...p, mass: v ?? 0 }))}
|
onChange={v => setPayload(p => ({ ...p, mass: v ?? 0 }))}
|
||||||
units="kg"
|
unitFamily="mass"
|
||||||
|
defaultUnitId="kg"
|
||||||
step="1"
|
step="1"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
<NumInput
|
<NumInput
|
||||||
label="Payload Bay Length"
|
label="Payload Bay Length"
|
||||||
value={payload.bayLength != null ? parseFloat((payload.bayLength * 1000).toPrecision(6)) : null}
|
value={payload.bayLength}
|
||||||
onChange={v => setPayload(p => ({ ...p, bayLength: v != null ? v / 1000 : 0 }))}
|
onChange={v => setPayload(p => ({ ...p, bayLength: v ?? 0 }))}
|
||||||
units="mm"
|
unitFamily="length"
|
||||||
step="10"
|
defaultUnitId="mm"
|
||||||
|
step="0.001"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
</DesignSection>
|
</DesignSection>
|
||||||
@@ -352,7 +383,9 @@ export default function RocketPage() {
|
|||||||
|
|
||||||
{/* ── Centre: 3D Model ── */}
|
{/* ── Centre: 3D Model ── */}
|
||||||
<div className="flex-1 relative border-r border-slate-700 bg-slate-950/50">
|
<div className="flex-1 relative border-r border-slate-700 bg-slate-950/50">
|
||||||
<RocketModel3D geometry={geometry} />
|
<ErrorBoundary>
|
||||||
|
<RocketModel3D geometry={geometry} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
|
||||||
{/* Requirements checklist — shown when model can't render */}
|
{/* Requirements checklist — shown when model can't render */}
|
||||||
{!geometry && engineData && (
|
{!geometry && engineData && (
|
||||||
|
|||||||
Reference in New Issue
Block a user