Knowledgebase
diff --git a/src/pages/SatellitePage.jsx b/src/pages/SatellitePage.jsx
new file mode 100644
index 0000000..a91c182
--- /dev/null
+++ b/src/pages/SatellitePage.jsx
@@ -0,0 +1,1169 @@
+import { useState, useMemo } from 'react'
+import { useSatelliteDesign } from '../hooks/useSatelliteDesign.js'
+import DesignSection from '../components/engine/DesignSection.jsx'
+import { formatValue } from '../engine/format.js'
+import { STRUCTURAL_MATERIALS } from '../engine/knowledgebaseData.js'
+import { CUBESAT_FORM_FACTORS, MODULATION_EB_N0 } from '../engine/satelliteCalcs.js'
+
+// ── Local primitives ────────────────────────────────────────────────────────
+
+function NumInput({ label, value, onChange, units, step, min, max, placeholder }) {
+ function handleChange(str) {
+ if (str === '') { onChange(null); return }
+ const v = parseFloat(str)
+ if (!isNaN(v)) onChange(v)
+ }
+ return (
+
+
+ handleChange(e.target.value)}
+ className="flex-1 min-w-0 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"
+ />
+ {units && {units}}
+
+ )
+}
+
+function SelectInput({ label, value, onChange, options }) {
+ return (
+
+
+
+
+ )
+}
+
+function TextInput({ label, value, onChange, placeholder }) {
+ return (
+
+
+ onChange(e.target.value)}
+ className="flex-1 min-w-0 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"
+ />
+
+ )
+}
+
+function CheckboxRow({ label, value, onChange }) {
+ return (
+
+
+ onChange(e.target.checked)}
+ className="w-4 h-4 accent-blue-500 cursor-pointer"
+ />
+
+ )
+}
+
+function ResultSection({ title, children }) {
+ return (
+
+ )
+}
+
+function ResultRow({ label, value, units, colorClass }) {
+ const color = colorClass ?? 'text-green-400'
+ return (
+
+ {label}
+
+ {value == null ? '—' : value}
+ {value != null && units ? {units} : null}
+
+
+ )
+}
+
+function StatusBadge({ status, labels }) {
+ const cfg = {
+ ok: { bg: 'bg-green-900/40 border-green-700', text: 'text-green-300' },
+ deficit: { bg: 'bg-red-900/40 border-red-700', text: 'text-red-300' },
+ failed: { bg: 'bg-red-900/40 border-red-700', text: 'text-red-300' },
+ marginal: { bg: 'bg-amber-900/40 border-amber-700', text: 'text-amber-300' },
+ overrun: { bg: 'bg-red-900/40 border-red-700', text: 'text-red-300' },
+ critical: { bg: 'bg-red-900/40 border-red-700', text: 'text-red-300' },
+ unknown: { bg: 'bg-slate-800 border-slate-600', text: 'text-slate-400' },
+ }
+ const s = cfg[status] ?? cfg.unknown
+ return (
+
+ {labels[status] ?? status}
+
+ )
+}
+
+// ── CubeSat SVG diagram ─────────────────────────────────────────────────────
+
+function CubeSatDiagram({ formFactor, deployablePanelArea }) {
+ const ff = CUBESAT_FORM_FACTORS[formFactor] ?? CUBESAT_FORM_FACTORS['3U']
+ const [W_mm,, L_mm] = ff.dims // width, (height is depth), length = long axis
+
+ // Scale body to fit in a ~120 × 200 px drawing area within 400×300 viewBox
+ const maxDrawW = 110, maxDrawH = 190
+ const scale = Math.min(maxDrawW / W_mm, maxDrawH / L_mm)
+ const drawW = W_mm * scale
+ const drawH = L_mm * scale
+ const cx = 200, cy = 148
+
+ const x0 = cx - drawW / 2
+ const y0 = cy - drawH / 2
+
+ const hasDeploy = deployablePanelArea > 0
+ const panelW = hasDeploy ? Math.min(78, Math.max(20, deployablePanelArea * 12000)) : 0
+ const panelH = drawH * 0.6
+ const panelY = cy - panelH / 2
+
+ return (
+
+ )
+}
+
+// ── Power orbit chart ───────────────────────────────────────────────────────
+
+function PowerOrbitChart({ orbitData, powerBudget, powerInputs }) {
+ const chartData = useMemo(() => {
+ if (!orbitData || !powerBudget || !powerBudget.C_batt) return null
+ const { t_sun, t_eclipse, T } = orbitData
+ const { chargeRateW, dischargeRateW, C_batt, DOD = 0.3 } = powerBudget
+ const DOD_val = powerInputs?.DOD ?? DOD ?? 0.3
+ const C_batt_Ws = C_batt * 3600 // Wh → Ws
+
+ const N = 120
+ const pts = []
+ let soc = 1.0 // start at full charge
+
+ // Steady-state: find starting SoC at eclipse entry
+ // SoC_min = 1 - DOD_val (minimum allowed)
+ // Actually let's simulate a full cycle starting from "end of sunlight"
+ for (let i = 0; i <= N; i++) {
+ const t = (i / N) * T
+ let s
+ if (t <= t_sun) {
+ // Sunlight: recharging from SoC after eclipse
+ // We start from SoC_after_eclipse and charge up
+ s = (1 - DOD_val) + (chargeRateW / C_batt_Ws) * t
+ } else {
+ // Eclipse: discharging
+ s = 1.0 - (dischargeRateW / C_batt_Ws) * (t - t_sun)
+ }
+ pts.push({ t, soc: Math.min(1.0, Math.max(0, s)) })
+ }
+ return { pts, t_sun, t_eclipse, T, DOD_val, soc_min: 1 - DOD_val }
+ }, [orbitData, powerBudget, powerInputs])
+
+ if (!chartData) {
+ return (
+
+
Enter orbit and power inputs to see chart
+
+ )
+ }
+
+ const { pts, t_sun, T, DOD_val } = chartData
+ const mg = { top: 24, right: 18, bottom: 36, left: 44 }
+ const W = 480, H = 170
+ const plotW = W - mg.left - mg.right
+ const plotH = H - mg.top - mg.bottom
+
+ const getX = t => mg.left + (t / T) * plotW
+ const getY = soc => mg.top + (1 - soc) * plotH
+
+ const linePts = pts.map(p => `${getX(p.t).toFixed(1)},${getY(p.soc).toFixed(1)}`).join(' ')
+
+ // Color segments: red below DOD line
+ const minY = getY(0)
+ const dodY = getY(1 - DOD_val)
+ const sunX = getX(t_sun)
+ const T_min = (T / 60).toFixed(1)
+ const tS_min = (t_sun / 60).toFixed(1)
+
+ return (
+
+ )
+}
+
+// ── Link waterfall chart ────────────────────────────────────────────────────
+
+function LinkWaterfallChart({ linkBudget }) {
+ if (!linkBudget?.cascade) {
+ return (
+
+
Enter link budget inputs to see chart
+
+ )
+ }
+
+ const cascade = linkBudget.cascade
+ const W = 480, H = 230
+ const labelW = 106, valueW = 56, barAreaW = W - labelW - valueW - 12
+ const rowH = 20, firstY = 18
+
+ // Scale bars by magnitude; FSPL dominates
+ const maxMag = Math.max(...cascade.map(r => Math.abs(r.delta)), 1)
+ const barScale = barAreaW * 0.9 / maxMag
+
+ const typeColor = { base: '#3b82f6', gain: '#10b981', loss: '#ef4444', noise: '#8b5cf6', req: '#f97316', result: '#60a5fa' }
+
+ const statusColor = linkBudget.status === 'ok' ? '#10b981' : linkBudget.status === 'marginal' ? '#f59e0b' : '#ef4444'
+
+ return (
+
+ )
+}
+
+// ── Thermal source chart ────────────────────────────────────────────────────
+
+function ThermalSourceChart({ thermalBalance }) {
+ if (!thermalBalance) {
+ return (
+
+
Enter thermal inputs to see chart
+
+ )
+ }
+
+ const { Q_sun, Q_earth_ir, Q_albedo, Q_int, Q_total_hot, Q_total_cold, T_hot, T_cold } = thermalBalance
+
+ const W = 480, H = 200
+ const leftW = 90, rightW = 110, barAreaW = W - leftW - rightW - 16
+ const rowH = 32
+
+ const maxQ = Math.max(Q_total_hot, 0.1)
+ const scaleBar = barAreaW / maxQ
+
+ const hotSegments = [
+ { label: 'Solar', val: Q_sun, color: '#fbbf24' },
+ { label: 'Earth IR', val: Q_earth_ir, color: '#ef4444' },
+ { label: 'Albedo', val: Q_albedo, color: '#f97316' },
+ { label: 'Internal', val: Q_int, color: '#8b5cf6' },
+ ]
+ const coldSegments = [
+ { label: 'Earth IR ×0.5', val: Q_earth_ir * 0.5, color: '#fca5a5' },
+ { label: 'Internal ×0.3', val: Q_int * 0.3, color: '#c4b5fd' },
+ ]
+
+ function StackedBar({ segments, total, y }) {
+ let x = leftW
+ const bars = segments.map((s, i) => {
+ const w = s.val * scaleBar
+ const bar =
+ x += w
+ return bar
+ })
+ return (
+ <>
+ {bars}
+ {/* Total width indicator */}
+
+ >
+ )
+ }
+
+ const T_hot_C = (T_hot - 273.15).toFixed(1)
+ const T_cold_C = (T_cold - 273.15).toFixed(1)
+ const hotColor = thermalBalance.status === 'ok' ? '#10b981' : thermalBalance.status === 'marginal' ? '#f59e0b' : '#ef4444'
+
+ return (
+
+ )
+}
+
+// ── Mass breakdown chart ────────────────────────────────────────────────────
+
+function MassBreakdownChart({ massBudget }) {
+ if (!massBudget) {
+ return (
+
+
Enter subsystem masses to see chart
+
+ )
+ }
+
+ const { breakdown, totalMass, limit } = massBudget
+ const W = 480, H = 220
+ const labelW = 78, valueW = 52
+ const barAreaW = W - labelW - valueW - 10
+ const rowH = 22
+ const firstY = 16
+
+ const maxBar = limit ?? Math.max(totalMass, 1)
+ const barScale = barAreaW / maxBar
+
+ const colors = {
+ structure: '#64748b', eps: '#f59e0b', adcs: '#10b981',
+ comms: '#3b82f6', cdh: '#8b5cf6', propulsion: '#ef4444', payload: '#06b6d4',
+ }
+ const limitX = labelW + limit * barScale
+
+ return (
+
+ )
+}
+
+// ── Main page ───────────────────────────────────────────────────────────────
+
+const MATERIAL_OPTIONS = STRUCTURAL_MATERIALS.map(m => ({ value: m.id, label: m.name }))
+
+const FORM_FACTOR_OPTIONS = Object.keys(CUBESAT_FORM_FACTORS).map(k => ({ value: k, label: k }))
+
+export default function SatellitePage() {
+ const {
+ orbitInputs, setOrbitInput,
+ powerInputs, setPowerInput,
+ linkInputs, setLinkInput,
+ thermalInputs, setThermalInput,
+ subsystems, setSubsystem,
+ orbitData,
+ powerBudget,
+ linkBudget,
+ thermalBalance,
+ massBudget,
+ } = useSatelliteDesign()
+
+ const [activeTab, setActiveTab] = useState('power')
+
+ const tabs = [
+ { id: 'power', label: 'Power' },
+ { id: 'link', label: 'Link' },
+ { id: 'thermal', label: 'Thermal' },
+ { id: 'subsystems', label: 'Subsystems' },
+ ]
+
+ // ── Helpers ──
+ function fmtTime(s) { return s == null ? null : `${(s / 60).toFixed(1)}` }
+ function fmtPct(f) { return f == null ? null : `${(f * 100).toFixed(1)} %` }
+ function fmtK(k) { return k == null ? null : `${k.toFixed(0)} K (${(k - 273.15).toFixed(1)} °C)` }
+ function fmtW(w) { return w == null ? null : formatValue(w) }
+ function fmtdB(v) { return v == null ? null : `${v.toFixed(2)}` }
+ function fmtM2(v) { return v == null ? null : v.toFixed(5) }
+
+ function marginColor(v) {
+ if (v == null) return undefined
+ return v >= 0 ? 'text-green-400' : 'text-red-400'
+ }
+ function thermalColor(T) {
+ if (T == null) return undefined
+ return T < 323 ? 'text-green-400' : T < 373 ? 'text-amber-400' : 'text-red-400'
+ }
+ function linkMarginColor(m) {
+ if (m == null) return undefined
+ return m >= 3 ? 'text-green-400' : m >= 0 ? 'text-amber-400' : 'text-red-400'
+ }
+
+ // ── Left panel content per tab ──
+ function renderLeftInputs() {
+ if (activeTab === 'power') return (
+ <>
+
+
+ setPowerInput('solarCellType', v)}
+ options={['Si', 'GaAs', 'InGaP']} />
+ setPowerInput('etaCell', v)} step={0.01} units="—" />
+ setPowerInput('etaPacking', v)} step={0.01} units="—" />
+ setPowerInput('deployablePanelArea', v ?? 0)}
+ step={0.001} min={0} units="m²" placeholder="0" />
+
+
+
+
+
+ setPowerInput('DOD', v)} step={0.05} min={0} max={1} units="—" />
+ setPowerInput('etaCharge', v)} step={0.01} units="—" />
+ setPowerInput('etaDischarge', v)} step={0.01} units="—" />
+ setPowerInput('designLife', v)} step={0.5} min={0} units="yr" />
+ setPowerInput('degradationPerYear', v)}
+ step={0.005} units="—" placeholder="-0.030" />
+
+
+
+
+
+ setPowerInput('P_sunlight_load', v)} step={0.1} min={0} units="W" />
+ setPowerInput('P_eclipse_load', v)} step={0.1} min={0} units="W" />
+
+
+ >
+ )
+
+ if (activeTab === 'link') return (
+ <>
+
+
+ setLinkInput('P_tx_W', v)} step={0.1} min={0} units="W" />
+ setLinkInput('G_tx_dBi', v)} step={0.5} units="dBi" />
+ setLinkInput('L_feed_dB', v)} step={0.1} min={0} units="dB" />
+ setLinkInput('frequencyMHz', v)} step={1} min={1} units="MHz" />
+ {
+ setLinkInput('modulation', v)
+ setLinkInput('Eb_N0_req_dB', MODULATION_EB_N0[v] ?? linkInputs.Eb_N0_req_dB)
+ }}
+ options={Object.keys(MODULATION_EB_N0)} />
+
+
+
+
+
+ setLinkInput('elevationAngleDeg', v)} step={1} min={1} max={90} units="°" />
+ setLinkInput('L_atm_dB', v)} step={0.1} min={0} units="dB" />
+ setLinkInput('L_point_dB', v)} step={0.1} min={0} units="dB" />
+
+
+
+
+
+ setLinkInput('G_rx_dBi', v)} step={0.5} units="dBi" />
+ setLinkInput('T_sys_K', v)} step={10} min={1} units="K" />
+ setLinkInput('dataRateBps', v)} step={100} min={1} units="bps" />
+ setLinkInput('Eb_N0_req_dB', v)} step={0.1} units="dB" />
+
+
+ >
+ )
+
+ if (activeTab === 'thermal') return (
+ <>
+
+
+ setThermalInput('alpha', v)} step={0.01} min={0} max={1} units="—" />
+ setThermalInput('epsilon', v)} step={0.01} min={0} max={1} units="—" />
+
+
+
+
+
+ setThermalInput('A_sun', v)} step={0.001} min={0} units="m²" />
+ setThermalInput('A_earth', v)} step={0.001} min={0} units="m²" />
+ setThermalInput('A_rad', v)} step={0.001} min={0} units="m²" />
+
+
+
+
+
+
setThermalInput('Q_int', v)} step={0.1} min={0} units="W" />
+
+ Orbit-averaged solar flux and Earth IR are derived from the orbit parameters above.
+
+
+
+ >
+ )
+
+ if (activeTab === 'subsystems') return (
+ <>
+
+
+ setSubsystem('adcs', { controlMode: v })}
+ options={['sun-pointing', 'nadir', '3-axis stabilised']} />
+ setSubsystem('adcs', { pointingAccuracyDeg: v })} step={0.5} units="°" />
+ setSubsystem('adcs', { reactionWheels: v })} />
+ setSubsystem('adcs', { magnetorquers: v })} />
+ setSubsystem('adcs', { mass: v ?? 0 })} step={10} min={0} units="g" />
+ setSubsystem('adcs', { avgPower: v ?? 0 })} step={0.1} min={0} units="W" />
+ setSubsystem('adcs', { peakPower: v ?? 0 })} step={0.1} min={0} units="W" />
+
+
+
+
+
+
setSubsystem('comms', { frequencyMHz: v })} step={1} units="MHz" />
+ setSubsystem('comms', { P_tx_W: v })} step={0.1} min={0} units="W" />
+ setSubsystem('comms', { antennaType: v })}
+ options={['dipole', 'patch', 'helical', 'yagi']} />
+ setSubsystem('comms', { modulation: v })}
+ options={Object.keys(MODULATION_EB_N0)} />
+ setSubsystem('comms', { mass: v ?? 0 })} step={10} min={0} units="g" />
+ setSubsystem('comms', { avgPower: v ?? 0 })} step={0.1} min={0} units="W" />
+ setSubsystem('comms', { peakPower: v ?? 0 })} step={0.1} min={0} units="W" />
+ Detailed link budget analysis in the Link tab.
+
+
+
+
+
+ setSubsystem('cdh', { processor: v })}
+ options={['ARM Cortex-M4', 'ARM Cortex-A9', 'LEON3', 'Raspberry Pi CM']} />
+ setSubsystem('cdh', { storageGB: v })} step={1} min={0} units="GB" />
+ setSubsystem('cdh', { housekeepingRateHz: v })} step={0.5} min={0} units="Hz" />
+ setSubsystem('cdh', { mass: v ?? 0 })} step={10} min={0} units="g" />
+ setSubsystem('cdh', { avgPower: v ?? 0 })} step={0.1} min={0} units="W" />
+ setSubsystem('cdh', { peakPower: v ?? 0 })} step={0.1} min={0} units="W" />
+
+
+
+
+
+
setSubsystem('eps', { batteryChemistry: v })}
+ options={['Li-ion', 'LiPo', 'NiH2']} />
+ setSubsystem('eps', { solarCellType: v })}
+ options={['Si', 'GaAs', 'InGaP']} />
+ setSubsystem('eps', { mass: v ?? 0 })} step={10} min={0} units="g" />
+ Solar array and battery sizing configured in Power tab.
+
+
+
+
+
+ setSubsystem('structure', { materialId: v })}
+ options={MATERIAL_OPTIONS} />
+ setSubsystem('structure', { configuration: v })}
+ options={['standard', 'custom']} />
+ setSubsystem('structure', { mass: v ?? 0 })} step={10} min={0} units="g" />
+
+
+
+
+
+ setSubsystem('propulsion', { type: v })}
+ options={[
+ { value: 'none', label: 'None' },
+ { value: 'cold_gas', label: 'Cold Gas' },
+ { value: 'monoprop', label: 'Monopropellant' },
+ { value: 'electrospray', label: 'Electrospray' },
+ ]} />
+ {subsystems.propulsion.type !== 'none' && (
+ <>
+ setSubsystem('propulsion', { propellant: v })}
+ placeholder="e.g. N₂, Hydrazine" />
+ setSubsystem('propulsion', { mass: v ?? 0 })} step={10} min={0} units="g" />
+ setSubsystem('propulsion', { avgPower: v ?? 0 })} step={0.1} min={0} units="W" />
+ setSubsystem('propulsion', { peakPower: v ?? 0 })} step={0.1} min={0} units="W" />
+ >
+ )}
+
+
+
+
+
+ setSubsystem('payload', { name: v })}
+ placeholder="e.g. Multispectral Imager" />
+ setSubsystem('payload', { type: v })}
+ options={['Earth Obs', 'Science', 'Comms', 'Tech Demo', 'Other']} />
+ setSubsystem('payload', { mass: v ?? 0 })} step={10} min={0} units="g" />
+ setSubsystem('payload', { avgPower: v ?? 0 })} step={0.1} min={0} units="W" />
+ setSubsystem('payload', { peakPower: v ?? 0 })} step={0.1} min={0} units="W" />
+ setSubsystem('payload', { interface: v })}
+ options={['UART', 'SPI', 'I2C', 'Ethernet', 'USB', 'Custom']} />
+
+
+ >
+ )
+ return null
+ }
+
+ // ── Right panel content per tab ──
+ function renderRightResults() {
+ if (activeTab === 'power') return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {powerBudget && (
+
+ )}
+ >
+ )
+
+ if (activeTab === 'link') return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {linkBudget && (
+
+ )}
+ >
+ )
+
+ if (activeTab === 'thermal') return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {thermalBalance && (
+
+ )}
+ >
+ )
+
+ if (activeTab === 'subsystems') return (
+ <>
+
+ {massBudget?.breakdown.map(row => (
+
+ ))}
+ {massBudget && (
+ <>
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ >
+ )
+ return null
+ }
+
+ return (
+
+ {/* Sub-header */}
+
+
+
Satellite Bus Design
+
+ {tabs.map(tab => (
+
+ ))}
+
+
+
+
+ {/* Three-column body */}
+
+ {/* ── Left: inputs ── */}
+
+ {/* Orbit & Mission always visible */}
+
+
+ setPowerInput('formFactor', v)}
+ options={FORM_FACTOR_OPTIONS} />
+ setOrbitInput('altitudeKm', v)} step={10} min={200} units="km" />
+ setOrbitInput('betaAngleDeg', v)} step={5} min={0} max={90} units="°"
+ placeholder="0" />
+
+
+
+ {renderLeftInputs()}
+
+
+ {/* ── Centre: CubeSat diagram + chart ── */}
+
+ {/* CubeSat diagram — fixed 240px height */}
+
+
+
+
+ {/* Tab chart — fills remaining space */}
+
+ {activeTab === 'power' &&
}
+ {activeTab === 'link' &&
}
+ {activeTab === 'thermal' &&
}
+ {activeTab === 'subsystems' &&
}
+
+
+
+ {/* ── Right: results ── */}
+
+ {renderRightResults()}
+
+
+
+ )
+}
diff --git a/src/pages/TrajectoryPage.jsx b/src/pages/TrajectoryPage.jsx
deleted file mode 100644
index aa9d078..0000000
--- a/src/pages/TrajectoryPage.jsx
+++ /dev/null
@@ -1,375 +0,0 @@
-import { useRef, useState } from 'react'
-import { useTrajectory } from '../hooks/useTrajectory.js'
-import { parseEngineImport, downloadBlob } from '../engine/engineExportImport.js'
-import { TrajectoryPlot } from '../components/trajectory/TrajectoryPlot.jsx'
-import { TimelineBar } from '../components/trajectory/TimelineBar.jsx'
-import ErrorBoundary from '../components/ErrorBoundary.jsx'
-import { formatValue } from '../engine/format.js'
-
-function ResultRow({ label, value, unit }) {
- const display = value !== null && value !== undefined && isFinite(value)
- ? formatValue(value)
- : '—'
- return (
-
-
{label}
-
-
- {display}
-
- {unit && {unit}}
-
-
- )
-}
-
-function NumInput({ label, value, onChange, units, step = 1 }) {
- return (
-
-
-
- onChange(parseFloat(e.target.value) || 0)}
- className="flex-1 px-2 py-1 bg-slate-800 border border-slate-600 rounded text-sm text-slate-100
- focus:border-blue-500 focus:outline-none"
- />
- {units && {units}}
-
-
- )
-}
-
-function SelectInput({ label, value, onChange, options }) {
- return (
-
-
-
-
- )
-}
-
-function ResultSection({ title, children }) {
- return (
-
-
- {title}
-
-
{children}
-
- )
-}
-
-export default function TrajectoryPage() {
- const trajectory = useTrajectory()
- const fileInputRef = useRef(null)
- const [importError, setImportError] = useState(null)
-
- // Import engine data from JSON
- const handleImport = async (e) => {
- const file = e.target.files?.[0]
- if (!file) return
-
- try {
- setImportError(null)
- const text = await file.text()
- const data = JSON.parse(text)
-
- // Handle engine design export
- if (data.type === 'engine_design') {
- const engineData = parseEngineImport(data)
-
- if (engineData.thermoResults) {
- const { F, Isp, mdot } = engineData.thermoResults
- const burnTime = data.burnTime || 30
-
- // Calculate m_dry and m_wet from propellant mass and burn time
- const m_prop = mdot * burnTime
- const m_dry = data.m_dry || 20
- const m_wet = m_dry + m_prop
-
- trajectory.updateConfig('F', F || 5000)
- trajectory.updateConfig('Isp', Isp || 250)
- trajectory.updateConfig('mdot', mdot || 2)
- trajectory.updateConfig('m_wet', m_wet)
- trajectory.updateConfig('m_dry', m_dry)
- trajectory.updateConfig('burnTime', burnTime)
-
- // Estimate A_ref from nozzle exit area if available
- if (data.nozzleGeometry?.A_e) {
- trajectory.updateConfig('A_ref', data.nozzleGeometry.A_e)
- }
- }
- }
- } catch (err) {
- setImportError(err.message)
- }
-
- if (fileInputRef.current) {
- fileInputRef.current.value = ''
- }
- }
-
- const { config, simulationResults, error, currentState } = trajectory
-
- return (
-
-
- {/* Main content area: left panel + center plot */}
-
- {/* Left panel */}
-
-
Trajectory Simulation
-
- {/* Import */}
-
-
-
- {importError && (
-
{importError}
- )}
-
-
- {/* Simulation parameters */}
-
-
- Engine Parameters
-
- trajectory.updateConfig('F', v)}
- units="N"
- step={100}
- />
- trajectory.updateConfig('Isp', v)}
- units="s"
- step={5}
- />
- trajectory.updateConfig('mdot', v)}
- units="kg/s"
- step={0.1}
- />
- trajectory.updateConfig('m_wet', v)}
- units="kg"
- />
- trajectory.updateConfig('m_dry', v)}
- units="kg"
- />
- trajectory.updateConfig('burnTime', v)}
- units="s"
- step={1}
- />
-
-
-
-
- Aerodynamics
-
- trajectory.updateConfig('Cd', v)}
- units=""
- step={0.01}
- />
- trajectory.updateConfig('A_ref', v)}
- units="m²"
- step={0.001}
- />
-
-
-
-
- Flight Profile
-
- trajectory.updateConfig('pitchStartAlt', v)}
- units="m"
- step={10}
- />
- trajectory.updateConfig('launchAngle', v)}
- units="°"
- step={1}
- />
- trajectory.updateConfig('dt', v)}
- units="s"
- step={0.01}
- />
- trajectory.updateConfig('t_max', v)}
- units="s"
- step={10}
- />
-
-
- {/* Run button */}
-
-
- {error && (
-
{error}
- )}
-
- {/* Results */}
- {simulationResults && (
-
-
-
-
-
-
-
-
-
-
- {currentState && (
-
-
-
-
-
-
-
-
- )}
-
- )}
-
-
- {/* Center: plot */}
- {simulationResults ? (
-
- trajectory.setCurrentTime(t)}
- />
-
- ) : (
-
-
- Import an engine and click "Run Simulation" to see trajectory
-
-
- )}
-
-
- {/* Timeline at bottom */}
- {simulationResults && (
-
trajectory.setIsPlaying(!trajectory.isPlaying)}
- playbackSpeed={trajectory.playbackSpeed}
- onPlaybackSpeedChange={trajectory.setPlaybackSpeed}
- />
- )}
-
-
- )
-}