init
This commit is contained in:
309
src/pages/KnowledgebaseFuelsPage.jsx
Normal file
309
src/pages/KnowledgebaseFuelsPage.jsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { SUBSTANCES, COMBINATIONS } from '../engine/knowledgebaseData.js'
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const ROLE_FILTERS = ['All', 'Fuels', 'Oxidisers', 'Monopropellants']
|
||||
const SUBCATEGORY_FILTERS = ['Cryogenic', 'Storable', 'Hypergolic', 'Green', 'Solid', 'Hybrid']
|
||||
|
||||
function roleBadgeClass(role) {
|
||||
if (role === 'fuel') return 'bg-blue-900 text-blue-200 border border-blue-700'
|
||||
if (role === 'oxidiser') return 'bg-orange-900 text-orange-200 border border-orange-700'
|
||||
return 'bg-yellow-900 text-yellow-200 border border-yellow-700'
|
||||
}
|
||||
|
||||
function roleLabel(role) {
|
||||
if (role === 'fuel') return 'FUEL'
|
||||
if (role === 'oxidiser') return 'OXIDISER'
|
||||
return 'MONOPROPELLANT'
|
||||
}
|
||||
|
||||
function subcategoryBadgeClass(sub) {
|
||||
const map = {
|
||||
Cryogenic: 'bg-cyan-900 text-cyan-200 border border-cyan-700',
|
||||
Storable: 'bg-slate-700 text-slate-200 border border-slate-500',
|
||||
Hypergolic: 'bg-red-900 text-red-200 border border-red-700',
|
||||
Green: 'bg-green-900 text-green-200 border border-green-700',
|
||||
Solid: 'bg-amber-900 text-amber-200 border border-amber-700',
|
||||
Hybrid: 'bg-purple-900 text-purple-200 border border-purple-700',
|
||||
}
|
||||
return map[sub] ?? 'bg-slate-700 text-slate-200 border border-slate-500'
|
||||
}
|
||||
|
||||
function PropRow({ label, value }) {
|
||||
if (value === null || value === undefined) return null
|
||||
return (
|
||||
<div className="grid grid-cols-[180px_1fr] gap-x-3 py-1.5 border-b border-slate-800">
|
||||
<span className="text-slate-400 text-sm">{label}</span>
|
||||
<span className="text-slate-100 text-sm">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function KnowledgebaseFuelsPage() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [roleFilter, setRoleFilter] = useState('All')
|
||||
const [subcatFilter, setSubcatFilter] = useState(null)
|
||||
const [selectedId, setSelectedId] = useState(SUBSTANCES[0].id)
|
||||
|
||||
// Filtered list
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
return SUBSTANCES.filter(s => {
|
||||
if (q && !s.name.toLowerCase().includes(q) && !s.symbol.toLowerCase().includes(q) && !s.formula.toLowerCase().includes(q)) return false
|
||||
if (roleFilter === 'Fuels' && s.role !== 'fuel') return false
|
||||
if (roleFilter === 'Oxidisers' && s.role !== 'oxidiser') return false
|
||||
if (roleFilter === 'Monopropellants' && s.role !== 'monopropellant') return false
|
||||
if (subcatFilter && s.subcategory !== subcatFilter) return false
|
||||
return true
|
||||
})
|
||||
}, [search, roleFilter, subcatFilter])
|
||||
|
||||
const selected = SUBSTANCES.find(s => s.id === selectedId) ?? SUBSTANCES[0]
|
||||
|
||||
// Combinations for the selected substance
|
||||
const combinations = useMemo(() => {
|
||||
if (selected.role === 'monopropellant') return []
|
||||
return COMBINATIONS.filter(c =>
|
||||
(selected.role === 'fuel' && c.fuelId === selected.id) ||
|
||||
(selected.role === 'oxidiser' && c.oxidiserId === selected.id)
|
||||
)
|
||||
}, [selected])
|
||||
|
||||
// Get partner substance for each combination
|
||||
function getPartner(combo) {
|
||||
const partnerId = selected.role === 'fuel' ? combo.oxidiserId : combo.fuelId
|
||||
return SUBSTANCES.find(s => s.id === partnerId)
|
||||
}
|
||||
|
||||
// Ensure selected item is still in filtered list; if not, show first filtered
|
||||
const effectiveSelected =
|
||||
filtered.find(s => s.id === selectedId)
|
||||
? selected
|
||||
: (filtered[0] ?? selected)
|
||||
|
||||
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 substances…"
|
||||
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>
|
||||
|
||||
{/* Role filter pills */}
|
||||
<div className="px-3 pt-2 pb-1 border-b border-slate-700 flex flex-wrap gap-1">
|
||||
{ROLE_FILTERS.map(r => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRoleFilter(r)}
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium transition-colors ${
|
||||
roleFilter === r
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Subcategory filter pills */}
|
||||
<div className="px-3 pt-2 pb-2 border-b border-slate-700 flex flex-wrap gap-1">
|
||||
{SUBCATEGORY_FILTERS.map(sc => (
|
||||
<button
|
||||
key={sc}
|
||||
onClick={() => setSubcatFilter(subcatFilter === sc ? null : sc)}
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium transition-colors ${
|
||||
subcatFilter === sc
|
||||
? 'bg-slate-500 text-white'
|
||||
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{sc}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Substance list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filtered.length === 0 && (
|
||||
<p className="px-4 py-6 text-sm text-slate-500 text-center">No substances match your filters.</p>
|
||||
)}
|
||||
{filtered.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setSelectedId(s.id)}
|
||||
className={`w-full text-left px-3 py-2.5 border-b border-slate-800 transition-colors ${
|
||||
effectiveSelected.id === s.id
|
||||
? 'bg-blue-900/40 border-l-2 border-l-blue-500'
|
||||
: 'hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-mono font-bold px-1.5 py-0.5 rounded ${roleBadgeClass(s.role)}`}>
|
||||
{s.symbol}
|
||||
</span>
|
||||
<span className="text-sm text-slate-100 font-medium truncate">{s.name}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-1">
|
||||
<span className={`text-[10px] px-1.5 rounded ${subcategoryBadgeClass(s.subcategory)}`}>
|
||||
{s.subcategory}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right panel ───────────────────────────────────────────────────── */}
|
||||
<div className="flex-1 overflow-y-auto bg-slate-950 p-6">
|
||||
<SubstanceDetail substance={effectiveSelected} combinations={combinations} getPartner={getPartner} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Substance detail view ────────────────────────────────────────────────────
|
||||
|
||||
function SubstanceDetail({ substance: s, combinations, getPartner }) {
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||
<h1 className="text-2xl font-bold text-white">{s.name}</h1>
|
||||
<span className="font-mono text-slate-400 text-lg">{s.symbol}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className={`text-xs font-bold px-2 py-1 rounded ${roleBadgeClass(s.role)}`}>
|
||||
{roleLabel(s.role)}
|
||||
</span>
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded ${subcategoryBadgeClass(s.subcategory)}`}>
|
||||
{s.subcategory}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-slate-300 text-sm leading-relaxed mb-8">{s.description}</p>
|
||||
|
||||
{/* Technical specs */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-3">
|
||||
Technical Specifications
|
||||
</h2>
|
||||
<div className="bg-slate-900 rounded-lg border border-slate-700 px-4 py-2">
|
||||
<PropRow label="Formula" value={s.formula} />
|
||||
<PropRow label="Molecular Weight" value={s.molecularWeight != null ? `${s.molecularWeight} g/mol` : null} />
|
||||
<PropRow
|
||||
label="Density"
|
||||
value={s.density != null ? `${s.density} kg/m³${s.densityNote ? ` (${s.densityNote})` : ''}` : null}
|
||||
/>
|
||||
<PropRow
|
||||
label="Boiling Point"
|
||||
value={s.boilingPoint != null ? `${s.boilingPoint} °C (at 1 atm)` : null}
|
||||
/>
|
||||
<PropRow
|
||||
label="Melting Point"
|
||||
value={s.meltingPoint != null ? `${s.meltingPoint} °C` : null}
|
||||
/>
|
||||
<PropRow label="Storage Pressure" value={s.storagePressure} />
|
||||
<PropRow
|
||||
label="Autoignition Temp."
|
||||
value={s.autoignitionTemp != null ? `${s.autoignitionTemp} °C` : 'N/A'}
|
||||
/>
|
||||
<PropRow label="Flammability Range" value={s.flammabilityRange} />
|
||||
<PropRow
|
||||
label="Hazards"
|
||||
value={s.hazards?.length ? s.hazards.join(' · ') : null}
|
||||
/>
|
||||
<PropRow label="Toxicity" value={s.toxicity} />
|
||||
{s.catalyticIsp != null && (
|
||||
<PropRow label="Catalytic Isp (vac.)" value={`${s.catalyticIsp} s`} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Engine examples */}
|
||||
{s.engineExamples?.length > 0 && (
|
||||
<section className="mb-8">
|
||||
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-3">
|
||||
Engine / Motor Examples
|
||||
</h2>
|
||||
<p className="text-slate-300 text-sm">{s.engineExamples.join(', ')}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Combinations */}
|
||||
{s.role !== 'monopropellant' && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-3">
|
||||
Bipropellant Combinations
|
||||
</h2>
|
||||
{combinations.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">No combination data available for this substance.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{combinations.map((combo, i) => {
|
||||
const partner = getPartner(combo)
|
||||
const partnerLabel = s.role === 'fuel'
|
||||
? `with ${partner?.name ?? combo.oxidiserId} (oxidiser)`
|
||||
: `with ${partner?.name ?? combo.fuelId} (fuel)`
|
||||
return (
|
||||
<div key={i} className="bg-slate-900 rounded-lg border border-slate-700 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<span className="text-white font-medium text-sm">{partnerLabel}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded border ${subcategoryBadgeClass(combo.energeticCategory.split(' ')[0])}`}>
|
||||
{combo.energeticCategory}
|
||||
</span>
|
||||
{combo.isHypergolic && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-orange-800 text-orange-200 border border-orange-600 font-semibold">
|
||||
Hypergolic
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-3">
|
||||
<Stat label="Vacuum Isp" value={combo.vacuumIsp != null ? `${combo.vacuumIsp} s` : '—'} />
|
||||
<Stat label="Flame Temp" value={combo.flameTemp != null ? `${combo.flameTemp} K` : '—'} />
|
||||
<Stat label="Optimal O/F" value={combo.optimalOF != null ? combo.optimalOF : '—'} />
|
||||
<Stat label="Ref. Chamber P" value={combo.chamberPressureRef ?? '—'} />
|
||||
</div>
|
||||
|
||||
{combo.notes && (
|
||||
<p className="text-slate-400 text-xs mb-2">{combo.notes}</p>
|
||||
)}
|
||||
{combo.engines?.length > 0 && (
|
||||
<p className="text-slate-500 text-xs">
|
||||
<span className="text-slate-400 font-medium">Engines: </span>
|
||||
{combo.engines.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value }) {
|
||||
return (
|
||||
<div className="bg-slate-800 rounded px-3 py-2">
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-0.5">{label}</div>
|
||||
<div className="text-sm font-semibold text-slate-100">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user