Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| } | |