Files
rocketry/src/pages/EnginePage.jsx
2026-03-04 16:28:07 +00:00

941 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef, useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { useEngineDesign } from '../hooks/useEngineDesign.js'
import { PropellantModal } from '../components/PropellantModal.jsx'
import {
exportEngineJSON,
exportEngineOdt,
parseEngineImport,
downloadBlob,
} from '../engine/engineExportImport.js'
import DesignSection from '../components/engine/DesignSection.jsx'
import EngineModel3D from '../components/engine/EngineModel3D.jsx'
import NozzleDiagram from '../components/engine/NozzleDiagram.jsx'
import PerformanceCharts from '../components/engine/PerformanceCharts.jsx'
import ErrorBoundary from '../components/ErrorBoundary.jsx'
import { formatValue } from '../engine/format.js'
import { getUnitsForFamily } from '../engine/units.js'
import { ENGINE_FIELD_INFO } from '../engine/engineFieldInfo.js'
import { ABLATIVE_MATERIALS, STRUCTURAL_MATERIALS } from '../engine/knowledgebaseData.js'
/* ── Info popup ───────────────────────────────────────────────────── */
function InfoPopup({ infoKey, anchorRef, onClose }) {
const popupRef = useRef(null)
const info = ENGINE_FIELD_INFO[infoKey]
const [pos, setPos] = useState({ top: 0, left: 0 })
useEffect(() => {
if (anchorRef.current) {
const r = anchorRef.current.getBoundingClientRect()
setPos({
top: r.bottom + 6,
left: Math.min(r.left, window.innerWidth - 230),
})
}
}, [anchorRef])
useEffect(() => {
function onKey(e) { if (e.key === 'Escape') onClose() }
function onDown(e) {
if (!popupRef.current?.contains(e.target) && !anchorRef.current?.contains(e.target)) {
onClose()
}
}
document.addEventListener('keydown', onKey)
document.addEventListener('mousedown', onDown)
return () => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('mousedown', onDown)
}
}, [onClose, anchorRef])
if (!info) return null
return createPortal(
<div
ref={popupRef}
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999 }}
className="bg-slate-800 border border-slate-600 rounded-lg p-3 shadow-2xl w-56 text-xs"
>
<p className="font-semibold text-slate-100 mb-1">{info.name}</p>
<p className="text-slate-300 mb-2 leading-relaxed">{info.description}</p>
{info.higher && <p className="text-emerald-400 mb-0.5"> {info.higher}</p>}
{info.lower && <p className="text-amber-400"> {info.lower}</p>}
</div>,
document.body,
)
}
/* ── Small reusable input components ──────────────────────────────── */
function NumInput({ label, value, onChange, units, step, placeholder, unitFamily, defaultUnitId, infoKey }) {
const unitList = unitFamily ? getUnitsForFamily(unitFamily) : null
const [selectedUnitId, setSelectedUnitId] = useState(
() => defaultUnitId ?? unitList?.[0]?.id ?? null,
)
const [popupOpen, setPopupOpen] = useState(false)
const infoRef = useRef(null)
const selectedUnit = unitList?.find(u => u.id === selectedUnitId) ?? unitList?.[0] ?? null
const displayValue = value == null
? ''
: selectedUnit ? selectedUnit.fromSI(value) : value
function handleChange(str) {
if (str === '') { onChange(null); return }
const typed = parseFloat(str)
if (isNaN(typed)) return
onChange(selectedUnit ? selectedUnit.toSI(typed) : typed)
}
return (
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 w-44 shrink-0">{label}</label>
<input
type="number"
value={displayValue}
step={step}
placeholder={placeholder ?? ''}
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
focus:border-blue-500 focus:outline-none placeholder-slate-600"
/>
{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}
{infoKey && (
<>
<button
ref={infoRef}
onClick={() => setPopupOpen(v => !v)}
className="text-slate-500 hover:text-slate-300 text-xs shrink-0 leading-none"
title="Info"
></button>
{popupOpen && (
<InfoPopup infoKey={infoKey} anchorRef={infoRef} onClose={() => setPopupOpen(false)} />
)}
</>
)}
</div>
)
}
function SelectInput({ label, value, onChange, options, infoKey }) {
const [popupOpen, setPopupOpen] = useState(false)
const infoRef = useRef(null)
return (
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400 w-44 shrink-0">{label}</label>
<select
value={value}
onChange={e => onChange(e.target.value)}
className="px-2 py-1 bg-slate-800 border border-slate-600 rounded text-sm text-slate-100
focus:border-blue-500 focus:outline-none"
>
{options.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{infoKey && (
<>
<button
ref={infoRef}
onClick={() => setPopupOpen(v => !v)}
className="text-slate-500 hover:text-slate-300 text-xs shrink-0 leading-none"
title="Info"
></button>
{popupOpen && (
<InfoPopup infoKey={infoKey} anchorRef={infoRef} onClose={() => setPopupOpen(false)} />
)}
</>
)}
</div>
)
}
/* ── Result display helpers ───────────────────────────────────────── */
function ResultRow({ label, value, unit, unitFamily, defaultUnitId, infoKey }) {
const unitList = unitFamily ? getUnitsForFamily(unitFamily) : null
const [selectedUnitId, setSelectedUnitId] = useState(
() => defaultUnitId ?? unitList?.[0]?.id ?? null,
)
const [popupOpen, setPopupOpen] = useState(false)
const infoRef = useRef(null)
const selectedUnit = unitList?.find(u => u.id === selectedUnitId) ?? unitList?.[0] ?? null
const rawDisplay = value !== null && value !== undefined && isFinite(value)
? (selectedUnit ? selectedUnit.fromSI(value) : value)
: null
const display = rawDisplay !== null ? formatValue(rawDisplay) : '—'
const unitLabel = selectedUnit?.label ?? unit
return (
<div className="flex items-center gap-2 text-sm">
<span className="text-slate-400 w-48 shrink-0">{label}</span>
<span className={`font-mono ${display === '—' ? 'text-slate-600' : 'text-green-400'}`}>
{display}
</span>
{unitList && unitList.length > 1 ? (
<select
value={selectedUnit?.id ?? ''}
onChange={e => setSelectedUnitId(e.target.value)}
className="px-1 py-0 bg-slate-700 border border-slate-600 rounded text-xs text-slate-400
focus:outline-none cursor-pointer"
>
{unitList.map(u => <option key={u.id} value={u.id}>{u.label}</option>)}
</select>
) : (
unitLabel && <span className="text-slate-500 text-xs">{unitLabel}</span>
)}
{infoKey && (
<>
<button
ref={infoRef}
onClick={() => setPopupOpen(v => !v)}
className="text-slate-500 hover:text-slate-300 text-xs leading-none shrink-0"
title="Info"
></button>
{popupOpen && (
<InfoPopup infoKey={infoKey} anchorRef={infoRef} onClose={() => setPopupOpen(false)} />
)}
</>
)}
</div>
)
}
function ResultSection({ title, children }) {
return (
<div className="mb-5">
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 pb-1 border-b border-slate-800">
{title}
</h3>
<div className="space-y-1">{children}</div>
</div>
)
}
/* ── Main page ────────────────────────────────────────────────────── */
export default function EnginePage() {
const importRef = useRef(null)
const [showPropellants, setShowPropellants] = useState(false)
const [showNozzleModal, setShowNozzleModal] = useState(false)
const [showChartsModal, setShowChartsModal] = useState(false)
const {
thermoInputs, setThermoInput,
chamber, setChamber,
nozzle, setNozzle,
injector, setInjector,
cooling, setCooling,
feedSystem, setFeedSystem,
burnTime, setBurnTime,
structure, setStructure,
allThermo,
chamberGeometry: cg,
nozzleGeometry: ng,
injectorGeometry: ig,
coolingResults: cr,
feedResults: fr,
structureResults: sr,
loadDesign,
applyPropellant,
} = useEngineDesign()
function handleExportJSON() {
const blob = exportEngineJSON({
thermoInputs, chamber, nozzle, injector, cooling, feedSystem, burnTime, structure,
allThermo, chamberGeometry: cg, nozzleGeometry: ng,
injectorGeometry: ig, coolingResults: cr, feedResults: fr, structureResults: sr,
})
downloadBlob(blob, 'engine-design.json')
}
async function handleExportODT() {
const blob = await exportEngineOdt({
allThermo, chamberGeometry: cg, nozzleGeometry: ng,
injectorGeometry: ig, coolingResults: cr, feedResults: fr,
})
downloadBlob(blob, 'engine-design.odt')
}
function handleImport(e) {
const file = e.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = ev => {
try {
const inputs = parseEngineImport(ev.target.result)
loadDesign(inputs)
} catch (err) {
alert('Import failed: ' + err.message)
}
}
reader.readAsText(file)
e.target.value = ''
}
return (
<div className="flex flex-col flex-1 overflow-hidden">
{/* Sub-header */}
<div className="flex items-center justify-between px-5 py-2 bg-slate-900 border-b border-slate-700 shrink-0">
<h1 className="text-sm font-semibold text-slate-200">Engine Design</h1>
<div className="flex gap-2">
<button
onClick={handleExportJSON}
className="px-3 py-1 text-xs bg-slate-700 hover:bg-slate-600 rounded text-slate-200 transition-colors"
>
JSON
</button>
<button
onClick={handleExportODT}
className="px-3 py-1 text-xs bg-slate-700 hover:bg-slate-600 rounded text-slate-200 transition-colors"
>
ODT
</button>
<button
onClick={() => importRef.current?.click()}
className="px-3 py-1 text-xs bg-slate-700 hover:bg-slate-600 rounded text-slate-200 transition-colors"
>
Import
</button>
<input
ref={importRef}
type="file"
accept=".json"
className="hidden"
onChange={handleImport}
/>
</div>
</div>
{/* Three-column layout */}
<div className="flex flex-1 overflow-hidden">
{/* ── Left: Inputs ── */}
<div className="w-[420px] shrink-0 overflow-y-auto p-4 border-r border-slate-700">
<DesignSection
title="Thermodynamic Inputs"
headerAction={
<button
onClick={() => setShowPropellants(true)}
className="px-2 py-1 text-xs bg-blue-700 hover:bg-blue-600 text-white rounded transition-colors whitespace-nowrap"
>
Load Propellant
</button>
}
>
<NumInput
label="Chamber Pressure (p₀)"
value={thermoInputs.p0}
onChange={v => setThermoInput('p0', v)}
unitFamily="pressure"
defaultUnitId="MPa"
infoKey="p0"
placeholder="3"
/>
<NumInput
label="Chamber Temp (T₀)"
value={thermoInputs.T0}
onChange={v => setThermoInput('T0', v)}
unitFamily="temperature"
defaultUnitId="K"
infoKey="T0"
placeholder="3500"
/>
<NumInput
label="Ratio of Specific Heats (γ)"
value={thermoInputs.gamma}
onChange={v => setThermoInput('gamma', v)}
infoKey="gamma"
placeholder="1.2"
step="0.01"
/>
<NumInput
label="Specific Gas Constant (R)"
value={thermoInputs.R}
onChange={v => setThermoInput('R', v)}
units="J/(kg·K)"
infoKey="R_gas"
placeholder="360"
/>
<NumInput
label="Mass Flow Rate (ṁ)"
value={thermoInputs.mdot}
onChange={v => setThermoInput('mdot', v)}
unitFamily="massflow"
defaultUnitId="kg/s"
infoKey="mdot"
placeholder="5"
/>
<NumInput
label="Thrust (F)"
value={thermoInputs.F}
onChange={v => setThermoInput('F', v)}
unitFamily="force"
defaultUnitId="kN"
infoKey="F_input"
placeholder="or enter ṁ"
/>
<NumInput
label="O/F Ratio"
value={thermoInputs.OF}
onChange={v => setThermoInput('OF', v)}
infoKey="OF"
placeholder="2.2"
step="0.1"
/>
<NumInput
label="Throat Area (Aₜ)"
value={thermoInputs.At}
onChange={v => setThermoInput('At', v)}
unitFamily="area"
defaultUnitId="cm²"
infoKey="At_input"
placeholder="auto-solved"
/>
<NumInput
label="Expansion Ratio (ε)"
value={thermoInputs.eps}
onChange={v => setThermoInput('eps', v)}
infoKey="eps_input"
placeholder="auto-solved"
step="0.5"
/>
<NumInput
label="Ambient Pressure (pₐ)"
value={thermoInputs.pa}
onChange={v => setThermoInput('pa', v)}
unitFamily="pressure"
defaultUnitId="kPa"
infoKey="pa"
placeholder="101.325"
/>
</DesignSection>
<DesignSection title="Combustion Chamber">
<NumInput
label="Characteristic Length (L*)"
value={chamber.Lstar}
onChange={v => setChamber(c => ({ ...c, Lstar: v }))}
unitFamily="length"
defaultUnitId="m"
infoKey="Lstar"
step="0.05"
/>
<NumInput
label="Contraction Ratio"
value={chamber.contractionRatio}
onChange={v => setChamber(c => ({ ...c, contractionRatio: v }))}
infoKey="contractionRatio"
step="0.5"
/>
<NumInput
label="Convergent Half-Angle"
value={chamber.convAngleDeg}
onChange={v => setChamber(c => ({ ...c, convAngleDeg: v }))}
units="°"
infoKey="convAngleDeg"
step="1"
/>
</DesignSection>
<DesignSection title="Nozzle">
<SelectInput
label="Nozzle Type"
value={nozzle.type}
onChange={v => setNozzle(n => ({ ...n, type: v }))}
options={[
{ value: 'conical', label: 'Conical' },
{ value: 'bell', label: 'Bell (80% length)' },
]}
infoKey="nozzleType"
/>
{nozzle.type === 'conical' && (
<NumInput
label="Divergence Half-Angle"
value={nozzle.divAngleDeg}
onChange={v => setNozzle(n => ({ ...n, divAngleDeg: v }))}
units="°"
infoKey="divAngleDeg"
step="1"
/>
)}
</DesignSection>
<DesignSection title="Injector">
<SelectInput
label="Injector Type"
value={injector.type}
onChange={v => setInjector(i => ({ ...i, type: v }))}
options={[
{ value: 'doublet', label: 'Impinging Doublet' },
{ value: 'triplet', label: 'Impinging Triplet' },
{ value: 'coaxial', label: 'Coaxial' },
{ value: 'pintle', label: 'Pintle' },
]}
infoKey="injectorType"
/>
<NumInput
label="Number of Elements (N)"
value={injector.N}
onChange={v => setInjector(i => ({ ...i, N: Math.max(1, Math.round(v)) }))}
infoKey="injectorN"
step="1"
/>
<NumInput
label="ΔP/p₀ Fraction"
value={injector.dpFraction}
onChange={v => setInjector(i => ({ ...i, dpFraction: v }))}
infoKey="dpFraction"
step="0.01"
/>
<NumInput
label="Discharge Coefficient (Cd)"
value={injector.Cd}
onChange={v => setInjector(i => ({ ...i, Cd: v }))}
infoKey="Cd"
step="0.01"
/>
<NumInput
label="Fuel Density (ρ_f)"
value={injector.rhoFuel}
onChange={v => setInjector(i => ({ ...i, rhoFuel: v }))}
unitFamily="density"
defaultUnitId="kg/m³"
infoKey="rhoFuel_inj"
/>
<NumInput
label="Oxidiser Density (ρ_ox)"
value={injector.rhoOx}
onChange={v => setInjector(i => ({ ...i, rhoOx: v }))}
unitFamily="density"
defaultUnitId="kg/m³"
infoKey="rhoOx_inj"
/>
</DesignSection>
<DesignSection title="Cooling">
<SelectInput
label="Cooling Method"
value={cooling.method}
onChange={v => setCooling(c => ({ ...c, method: v }))}
options={[
{ value: 'regenerative', label: 'Regenerative' },
{ value: 'film', label: 'Film Cooling' },
{ value: 'ablative', label: 'Ablative' },
{ value: 'uncooled', label: 'Uncooled' },
]}
infoKey="coolingMethod"
/>
{cooling.method === 'regenerative' && (
<>
<NumInput
label="Channel Count"
value={cooling.channelCount}
onChange={v => setCooling(c => ({ ...c, channelCount: Math.max(1, Math.round(v)) }))}
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' && (
<NumInput
label="Film Mass Fraction"
value={cooling.filmFraction}
onChange={v => setCooling(c => ({ ...c, filmFraction: v }))}
infoKey="filmFraction"
step="0.01"
/>
)}
{cooling.method === 'ablative' && (
<>
<SelectInput
label="Liner Material"
value={cooling.ablativeMaterial}
onChange={v => setCooling(c => ({ ...c, ablativeMaterial: v }))}
options={ABLATIVE_MATERIALS.map(m => ({ value: m.id, label: m.name }))}
/>
<NumInput
label="Initial Thickness"
value={cooling.ablativeThickness}
onChange={v => setCooling(c => ({ ...c, ablativeThickness: v }))}
units="mm"
step="1"
placeholder="10"
/>
</>
)}
</DesignSection>
<DesignSection title="Feed System">
<SelectInput
label="Feed System"
value={feedSystem.type}
onChange={v => setFeedSystem(f => ({ ...f, type: v }))}
options={[
{ value: 'pressure_fed', label: 'Pressure-Fed' },
{ value: 'pump_fed', label: 'Pump-Fed' },
]}
infoKey="feedType"
/>
{feedSystem.type === 'pressure_fed' && (
<NumInput
label="Feed Pressure Factor"
value={feedSystem.feedFactor}
onChange={v => setFeedSystem(f => ({ ...f, feedFactor: v }))}
infoKey="feedFactor"
step="0.05"
/>
)}
<NumInput
label="Fuel Density (ρ_f)"
value={feedSystem.rhoFuel}
onChange={v => setFeedSystem(f => ({ ...f, rhoFuel: v }))}
unitFamily="density"
defaultUnitId="kg/m³"
infoKey="rhoFuel_feed"
/>
<NumInput
label="Oxidiser Density (ρ_ox)"
value={feedSystem.rhoOx}
onChange={v => setFeedSystem(f => ({ ...f, rhoOx: v }))}
unitFamily="density"
defaultUnitId="kg/m³"
infoKey="rhoOx_feed"
/>
<NumInput
label="Burn Time"
value={burnTime}
onChange={v => setBurnTime(v ?? 30)}
unitFamily="time"
defaultUnitId="s"
infoKey="burnTime"
step="1"
/>
</DesignSection>
<DesignSection title="Structural">
<SelectInput
label="Material"
value={structure.materialId}
onChange={v => setStructure(s => ({ ...s, materialId: v }))}
options={STRUCTURAL_MATERIALS.map(m => ({ value: m.id, label: m.name }))}
infoKey="structuralMaterial"
/>
<NumInput
label="Safety Factor"
value={structure.safetyFactor}
onChange={v => setStructure(s => ({ ...s, safetyFactor: v }))}
infoKey="safetyFactor"
step="0.1"
placeholder="2.0"
/>
</DesignSection>
</div>
{/* ── Centre: 3D Model + Visualization Buttons ── */}
<div className="flex-1 border-r border-slate-700 bg-slate-950/50 flex flex-col overflow-hidden">
{/* 3D Model */}
<div className="flex-1 relative flex flex-col">
<ErrorBoundary>
<EngineModel3D chamberGeometry={cg} nozzleGeometry={ng} />
</ErrorBoundary>
{/* Quick access buttons overlay */}
<div className="absolute bottom-4 left-4 right-4 flex gap-2 pointer-events-auto">
{cg && ng && (
<button
onClick={() => setShowNozzleModal(true)}
className="px-3 py-2 text-xs bg-blue-700 hover:bg-blue-600 text-white rounded transition-colors whitespace-nowrap"
>
📐 Nozzle Cross-Section
</button>
)}
{allThermo?.gamma && allThermo?.R && allThermo?.T0 && allThermo?.p0 && (
<button
onClick={() => setShowChartsModal(true)}
className="px-3 py-2 text-xs bg-indigo-700 hover:bg-indigo-600 text-white rounded transition-colors whitespace-nowrap"
>
📊 Performance Charts
</button>
)}
</div>
</div>
</div>
{/* ── Right: Results ── */}
<div className="w-[380px] shrink-0 overflow-y-auto p-4">
<ResultSection title="Thermodynamic Performance">
<ResultRow label="Throat Area (Aₜ)" value={allThermo.At} unitFamily="area" defaultUnitId="cm²" infoKey="At_result" />
<ResultRow label="Exit Area (Aₑ)" value={allThermo.Ae} unitFamily="area" defaultUnitId="cm²" infoKey="Ae_result" />
<ResultRow label="Expansion Ratio (ε)" value={allThermo.eps} unit="—" infoKey="eps_result" />
<ResultRow label="Exit Mach (Mₑ)" value={allThermo.Me} unit="—" infoKey="Me_result" />
<ResultRow label="Exit Temp (Tₑ)" value={allThermo.Te} unitFamily="temperature" defaultUnitId="K" infoKey="Te_result" />
<ResultRow label="Exit Pressure (pₑ)" value={allThermo.pe} unitFamily="pressure" defaultUnitId="kPa" infoKey="pe_result" />
<ResultRow label="Exhaust Velocity (Vₑ)" value={allThermo.Ve} unitFamily="velocity" defaultUnitId="m/s" infoKey="Ve_result" />
<ResultRow label="Thrust (F)" value={allThermo.F} unitFamily="force" defaultUnitId="kN" infoKey="F_result" />
<ResultRow label="Specific Impulse (Isp)" value={allThermo.Isp} unit="s" infoKey="Isp_result" />
<ResultRow label="c*" value={allThermo.cstar} unitFamily="velocity" defaultUnitId="m/s" infoKey="cstar_result" />
<ResultRow label="Thrust Coefficient (CF)" value={allThermo.CF} unit="—" infoKey="CF_result" />
<ResultRow label="Fuel Flow (ṁ_f)" value={allThermo.mdot_f} unitFamily="massflow" defaultUnitId="kg/s" infoKey="mdot_f_result" />
<ResultRow label="Ox Flow (ṁ_ox)" value={allThermo.mdot_ox} unitFamily="massflow" defaultUnitId="kg/s" infoKey="mdot_ox_result" />
</ResultSection>
<ResultSection title="Chamber Geometry">
<ResultRow label="Chamber Diameter (Dc)" value={cg?.Dc} unitFamily="length" defaultUnitId="mm" infoKey="Dc_result" />
<ResultRow label="Throat Diameter (Dt)" value={cg?.Dt} unitFamily="length" defaultUnitId="mm" infoKey="Dt_result" />
<ResultRow label="Contraction Ratio" value={cg?.contractionRatio} unit="—" infoKey="contractionRatio_result" />
<ResultRow label="Total Length (Lc)" value={cg?.Lc} unitFamily="length" defaultUnitId="mm" infoKey="Lc_result" />
<ResultRow label="Cylindrical Length" value={cg?.L_cyl} unitFamily="length" defaultUnitId="mm" infoKey="L_cyl_result" />
<ResultRow label="Convergent Length" value={cg?.L_conv} unitFamily="length" defaultUnitId="mm" infoKey="L_conv_result" />
<ResultRow label="Chamber Volume" value={cg?.Vc} unitFamily="volume" defaultUnitId="cm³" infoKey="Vc_result" />
</ResultSection>
<ResultSection title="Nozzle Geometry">
<ResultRow label="Throat Diameter (Dt)" value={ng?.Dt} unitFamily="length" defaultUnitId="mm" infoKey="Dt_result" />
<ResultRow label="Exit Diameter (De)" value={ng?.De} unitFamily="length" defaultUnitId="mm" infoKey="De_result" />
<ResultRow label="Nozzle Length (Ln)" value={ng?.Ln} unitFamily="length" defaultUnitId="mm" infoKey="Ln_result" />
</ResultSection>
<ResultSection title="Injector">
<ResultRow label="Pressure Drop (ΔP)" value={ig?.deltaP} unitFamily="pressure" defaultUnitId="kPa" infoKey="deltaP_result" />
<ResultRow label="Fuel Jet Velocity" value={ig?.v_f} unitFamily="velocity" defaultUnitId="m/s" infoKey="v_f_result" />
<ResultRow label="Oxidiser Jet Velocity" value={ig?.v_ox} unitFamily="velocity" defaultUnitId="m/s" infoKey="v_ox_result" />
<ResultRow label="Fuel Orifice Diameter" value={ig?.d_f} unitFamily="length" defaultUnitId="mm" infoKey="d_f_result" />
<ResultRow label="Oxidiser Orifice Diameter" value={ig?.d_ox} unitFamily="length" defaultUnitId="mm" infoKey="d_ox_result" />
</ResultSection>
<ResultSection title="Cooling">
{cr ? (
<>
{cr.method === 'regenerative' && (
<>
<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="Channel Count" value={cr.channelCount} unit="—" infoKey="channelCount_result" />
<ResultRow label="Heat load / channel" value={cr.q_perChannel} unit="W" infoKey="q_perChannel_result" />
</>
)}
{cr.method === 'film' && (
<>
<ResultRow label="Film Mass Flow" value={cr.mdot_film} unitFamily="massflow" defaultUnitId="g/s" infoKey="mdot_film_result" />
<ResultRow label="Est. Isp Penalty" value={cr.ispPenalty} unit="%" infoKey="ispPenalty_result" />
</>
)}
{cr.method === 'ablative' && cr.material && (
<>
<div className="text-sm mb-2">
<span className="text-slate-400">Material: </span>
<span className="text-slate-100 font-semibold">{cr.material.name}</span>
</div>
{cr.pressureFactor && Math.abs(cr.pressureFactor - 1.0) > 0.01 && (
<ResultRow label="Pressure Correction" value={cr.pressureFactor} unit="×" />
)}
<ResultRow label="Erosion Rate" value={cr.effectiveRate} unit="mm/s" />
<ResultRow label="Initial Thickness" value={cr.ablativeThickness} unit="mm" />
<div className="flex items-center gap-2 text-sm py-1.5">
<span className="text-slate-400 w-48 shrink-0">Eroded</span>
<span className="font-mono text-green-400">
{formatValue(cr.erosionMm)} mm
</span>
<span className="text-xs text-slate-500">
({formatValue(cr.erosionMmMin)}{formatValue(cr.erosionMmMax)} mm)
</span>
</div>
<div className="flex items-center gap-2 text-sm py-1.5">
<span className="text-slate-400 w-48 shrink-0">Remaining Thickness</span>
<span className={`font-mono font-semibold ${
cr.status === 'critical' ? 'text-red-400' :
cr.status === 'warning' ? 'text-amber-400' :
'text-green-400'
}`}>
{formatValue(cr.remainingMm)} mm
</span>
<span className="text-xs text-slate-500">
(worst: {formatValue(cr.remainingMmWorst)} mm)
</span>
</div>
{(cr.status === 'warning' || cr.status === 'critical') && (
<div className={`text-xs px-3 py-2 rounded mt-2 ${
cr.status === 'critical'
? 'bg-red-900/30 border border-red-700 text-red-200'
: 'bg-amber-900/30 border border-amber-700 text-amber-200'
}`}>
{cr.status === 'critical'
? '🚨 CRITICAL: Liner thickness below safe minimum!'
: '⚠️ WARNING: Liner thickness approaching minimum!'}
</div>
)}
</>
)}
{cr.note && (
<p className="text-xs text-slate-400 italic mt-1">{cr.note}</p>
)}
</>
) : (
<p className="text-xs text-slate-600"></p>
)}
</ResultSection>
<ResultSection title="Feed System">
{fr ? (
<>
<ResultRow label="Tank Pressure" value={fr.p_tank} unitFamily="pressure" defaultUnitId="MPa" infoKey="p_tank_result" />
<ResultRow label="Fuel Volume" value={fr.V_fuel} unitFamily="volume" defaultUnitId="L" infoKey="V_fuel_result" />
<ResultRow label="Oxidiser Volume" value={fr.V_ox} unitFamily="volume" defaultUnitId="L" infoKey="V_ox_result" />
<ResultRow label="Total Propellant Volume" value={fr.V_prop} unitFamily="volume" defaultUnitId="L" infoKey="V_prop_result" />
{fr.m_press != null && <ResultRow label="Pressurant Mass" value={fr.m_press} unitFamily="mass" defaultUnitId="kg" infoKey="m_press_result" />}
{fr.dP_pump != null && <ResultRow label="Pump ΔP" value={fr.dP_pump} unitFamily="pressure" defaultUnitId="MPa" infoKey="dP_pump_result" />}
{fr.P_turbine != null && <ResultRow label="Est. Turbine Power" value={fr.P_turbine / 1000} unit="kW" infoKey="P_turbine_result" />}
</>
) : (
<p className="text-xs text-slate-600"></p>
)}
</ResultSection>
<ResultSection title="Structural Sizing">
{sr ? (
<>
<div className="text-sm mb-2">
<span className="text-slate-400">Material: </span>
<span className="text-slate-100 font-semibold">{sr.material.name}</span>
</div>
<ResultRow label="Safety Factor" value={sr.safetyFactor} unit="—" />
<ResultRow label="Allowable Stress" value={sr.allowableStress / 1e6} unit="MPa" />
<div className="border-t border-slate-700 my-2 pt-2">
<ResultRow label="Chamber Wall Thickness" value={sr.t_chamber * 1000} unit="mm" />
<ResultRow label="Throat Wall Thickness" value={sr.t_throat * 1000} unit="mm" />
<ResultRow label="Nozzle Exit Wall Thickness" value={sr.t_nozzle_exit * 1000} unit="mm" />
</div>
<div className="border-t border-slate-700 my-2 pt-2">
<ResultRow label="Chamber Mass" value={sr.m_chamber_cyl + sr.m_convergent} unitFamily="mass" defaultUnitId="kg" />
<ResultRow label="Nozzle Mass" value={sr.m_nozzle} unitFamily="mass" defaultUnitId="kg" />
<ResultRow label="Injector Plate Mass" value={sr.m_injector} unitFamily="mass" defaultUnitId="kg" />
</div>
<div className="border-t border-slate-700 mt-2 pt-2">
<div className="flex items-center gap-2 text-sm bg-blue-900/30 border border-blue-700 rounded px-3 py-2">
<span className="text-slate-400">Engine Dry Mass</span>
<span className="font-mono font-semibold text-blue-300">
{formatValue(sr.m_total)} kg
</span>
</div>
</div>
</>
) : (
<p className="text-xs text-slate-600"></p>
)}
</ResultSection>
</div>
</div>
{showPropellants && (
<PropellantModal
onClose={() => setShowPropellants(false)}
onApply={applyPropellant}
description="Select a propellant to pre-fill γ, R, T₀, O/F, and densities."
/>
)}
{/* Nozzle Diagram Modal */}
{showNozzleModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-slate-900 rounded-lg shadow-2xl border border-slate-700 w-full max-w-4xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-slate-700 shrink-0">
<h2 className="text-lg font-semibold text-slate-100">Nozzle Cross-Section</h2>
<button
onClick={() => setShowNozzleModal(false)}
className="text-slate-400 hover:text-slate-200 text-xl"
>
</button>
</div>
<div className="flex-1 overflow-auto p-6 bg-slate-800">
<ErrorBoundary>
<NozzleDiagram chamberGeometry={cg} nozzleGeometry={ng} nozzleType={nozzle.type} />
</ErrorBoundary>
</div>
</div>
</div>
)}
{/* Performance Charts Modal */}
{showChartsModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-slate-900 rounded-lg shadow-2xl border border-slate-700 w-full max-w-5xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-slate-700 shrink-0">
<h2 className="text-lg font-semibold text-slate-100">Performance Analysis</h2>
<button
onClick={() => setShowChartsModal(false)}
className="text-slate-400 hover:text-slate-200 text-xl"
>
</button>
</div>
<div className="flex-1 overflow-auto p-6 bg-slate-800">
<ErrorBoundary>
<PerformanceCharts
allThermo={allThermo}
coolingResults={cr}
burnTime={burnTime}
nozzleType={nozzle.type}
/>
</ErrorBoundary>
</div>
</div>
</div>
)}
</div>
)
}