microbe-model / web /src /components /TestTab.jsx
Miyu Horiuchi
Surface hybrid oxygen source in UI
572e624
import React, { useState } from 'react';
import { THEME, tempColor, pHColor, saltColor } from '../theme.js';
import { OxygenConfArc, IntervalBar, MediaConfBar, MonoTag, SourceBadge } from './Primitives.jsx';
const PRESETS = [
{ id: 'ecoli', name: 'Escherichia coli K-12 MG1655', accession: 'GCF_000005845.2',
published: { T_opt: 37, pH: 7.0, salt: 1, O2: 'facultative anaerobe', medium: 'LB (Luria-Bertani)' } },
{ id: 'bsub', name: 'Bacillus subtilis 168', accession: 'GCF_000009045.1',
published: { T_opt: 30, pH: 7.0, salt: 2, O2: 'facultative anaerobe', medium: 'LB or Nutrient Broth' } },
{ id: 'tt', name: 'Thermus thermophilus HB8', accession: 'GCF_000091545.1',
published: { T_opt: 70, pH: 7.5, salt: 0.5, O2: 'aerobe', medium: 'DSMZ 74 Castenholz TYE' } },
];
export default function TestTab() {
const [selected, setSelected] = useState(PRESETS[0]);
const [result, setResult] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
async function predict(preset) {
setSelected(preset);
setBusy(true);
setError(null);
try {
const r = await fetch('/api/predict', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: preset.accession, top_k: 5 }),
});
if (!r.ok) throw new Error(await r.text());
setResult(await r.json());
} catch (e) {
setError(String(e.message || e));
} finally {
setBusy(false);
}
}
return (
<div style={{ flex: 1, overflow: 'auto', background: THEME.paper, padding: '24px 28px' }}>
<div style={{ font: `400 12px ${THEME.mono}`, color: THEME.inkSoft, marginBottom: 14 }}>
Sanity-check the model on a microbe with published growth conditions.
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 22 }}>
{PRESETS.map((p) => (
<button key={p.id} onClick={() => predict(p)} style={{
textAlign: 'left', padding: '12px 14px',
background: selected.id === p.id ? '#ede4cd' : THEME.paper,
border: `1px solid ${selected.id === p.id ? THEME.ink : THEME.rule}`,
cursor: 'pointer', borderRadius: 2,
}}>
<div style={{ font: `500 13.5px ${THEME.serif}`, fontStyle: 'italic', color: THEME.ink, marginBottom: 4 }}>{p.name}</div>
<div style={{ font: `400 11px ${THEME.mono}`, color: THEME.inkFaint }}>{p.accession}</div>
<div style={{ display: 'flex', gap: 10, marginTop: 8, font: `400 11px ${THEME.mono}`, color: THEME.inkSoft }}>
<span>{p.published.T_opt}°C</span>
<span>pH {p.published.pH}</span>
<span>{p.published.O2}</span>
</div>
</button>
))}
</div>
{busy && <div style={{ fontFamily: THEME.mono, color: THEME.inkSoft, fontSize: 12 }}>Predicting…</div>}
{error && <div style={{ padding: '8px 12px', background: '#f5d8c8', border: `1px solid ${THEME.warn}`, fontFamily: THEME.font, fontSize: 12, color: THEME.ink }}>{error}</div>}
{result && <Comparison preset={selected} result={result} />}
</div>
);
}
function CompareCard({ label, pred, lo, hi, pub, unit, color, scaleMin, scaleMax }) {
const inInterval = pub >= lo && pub <= hi;
const pubPct = ((pub - scaleMin) / (scaleMax - scaleMin)) * 100;
return (
<div style={{ border: `1px solid ${THEME.rule}`, padding: '14px 16px', background: THEME.paper }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 10 }}>
<span style={{ font: `500 11px ${THEME.mono}`, color: THEME.inkSoft, letterSpacing: '0.05em', textTransform: 'uppercase' }}>{label}</span>
<span style={{ font: `500 11px ${THEME.mono}`, color: inInterval ? THEME.pos : THEME.warn }}>
{inInterval ? '✓ in 80% PI' : '△ outside PI'}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 18, alignItems: 'center' }}>
<div>
<div style={{ font: `400 10px ${THEME.mono}`, color: THEME.inkFaint, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>predicted</div>
<div style={{ font: `500 22px ${THEME.serif}`, color, fontVariantNumeric: 'tabular-nums' }}>{pred.toFixed(1)}{unit}</div>
<div style={{ font: `400 10px ${THEME.mono}`, color: THEME.inkFaint, marginTop: 2 }}>{lo.toFixed(1)}{unit} – {hi.toFixed(1)}{unit}</div>
</div>
<div>
<div style={{ font: `400 10px ${THEME.mono}`, color: THEME.inkFaint, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>published</div>
<div style={{ font: `500 22px ${THEME.serif}`, color: THEME.ink, fontVariantNumeric: 'tabular-nums' }}>{pub}{unit}</div>
<div style={{ font: `400 10px ${THEME.mono}`, color: THEME.inkFaint, marginTop: 2 }}>literature</div>
</div>
</div>
<div style={{ marginTop: 12 }}>
<IntervalBar value={pred} lo={lo} hi={hi} scaleMin={scaleMin} scaleMax={scaleMax} color={color} height={6} />
<div style={{ position: 'relative', height: 0, marginTop: -4 }}>
<div style={{ position: 'absolute', left: `${pubPct}%`, transform: 'translateX(-50%)', top: 8, fontFamily: THEME.mono, fontSize: 9, fontWeight: 500, color: THEME.ink }}>↑ pub</div>
</div>
</div>
</div>
);
}
function Comparison({ preset, result }) {
const p = result.phenotypes;
const pub = preset.published;
const T = p.optimal_temperature_c;
const pH = p.optimal_ph;
const salt = p.salt_tolerance_pct;
const O2 = p.oxygen_requirement;
const top = result.media?.[0];
return (
<div>
<div style={{ font: `500 11px ${THEME.mono}`, color: THEME.inkSoft, letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 12 }}>
Predicted vs published — {preset.name}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
{T && <CompareCard label="Optimum temperature" pred={T.prediction} lo={T.low_80} hi={T.high_80} pub={pub.T_opt} unit="°C" color={tempColor(T.prediction)} scaleMin={0} scaleMax={110} />}
{pH && <CompareCard label="Optimum pH" pred={pH.prediction} lo={pH.low_80} hi={pH.high_80} pub={pub.pH} unit="" color={pHColor(pH.prediction)} scaleMin={2} scaleMax={11} />}
{salt && <CompareCard label="Salt tolerance" pred={salt.prediction} lo={salt.low_80} hi={salt.high_80} pub={pub.salt} unit="%" color={saltColor(salt.prediction)} scaleMin={0} scaleMax={25} />}
{O2 && (
<div style={{ border: `1px solid ${THEME.rule}`, padding: '14px 16px', background: THEME.paper }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 10 }}>
<span style={{ font: `500 11px ${THEME.mono}`, color: THEME.inkSoft, letterSpacing: '0.05em', textTransform: 'uppercase' }}>Oxygen requirement</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<SourceBadge source={O2.source} compact />
<span style={{ font: `500 11px ${THEME.mono}`, color: O2.prediction === pub.O2 ? THEME.pos : THEME.warn }}>
{O2.prediction === pub.O2 ? '✓ match' : '△ mismatch'}
</span>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 18, alignItems: 'center' }}>
<div>
<div style={{ font: `400 10px ${THEME.mono}`, color: THEME.inkFaint, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>predicted</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<OxygenConfArc value={O2.confidence} size={32} />
<div style={{ font: `500 14px ${THEME.font}`, color: THEME.ink }}>{O2.prediction}</div>
</div>
</div>
<div>
<div style={{ font: `400 10px ${THEME.mono}`, color: THEME.inkFaint, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>published</div>
<div style={{ font: `500 16px ${THEME.font}`, color: THEME.ink }}>{pub.O2}</div>
</div>
</div>
</div>
)}
</div>
<div style={{ marginTop: 22, font: `500 11px ${THEME.mono}`, color: THEME.inkSoft, letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 8 }}>
Top media to try
</div>
{result.media?.slice(0, 5).map((m, i) => (
<div key={m.medium_id} style={{
border: `1px solid ${i === 0 ? THEME.accent : THEME.rule}`,
padding: '12px 14px', background: i === 0 ? '#fdf6e8' : THEME.paper, marginBottom: 8, borderRadius: 2,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<MonoTag>{m.medium_id}</MonoTag>
<span style={{ font: `500 13px ${THEME.font}`, color: THEME.ink, flex: 1 }}>{m.name}</span>
<MediaConfBar value={m.confidence} />
</div>
{m.recipe && (
<div style={{ font: `400 11px ${THEME.mono}`, color: THEME.inkSoft, lineHeight: 1.4, marginTop: 6 }}>
{m.recipe}
</div>
)}
</div>
))}
</div>
);
}