| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { useState, useCallback } from 'react' |
| import { |
| BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Cell, ResponsiveContainer, |
| } from 'recharts' |
| import { |
| ArrowLeftRight, TrendingUp, TrendingDown, Minus, Loader2, AlertCircle, |
| ChevronDown, CheckCircle2, XCircle, |
| } from 'lucide-react' |
| import { api } from '../api' |
| import { useDisease } from '../context/DiseaseContext' |
| import { useDiseaseSchema } from '../hooks/useDiseaseSchema' |
| import { getPatientsForDisease } from '../mockPatients' |
|
|
| |
|
|
| function riskScore(result) { |
| if (!result) return null |
| return result.prediction === 1 ? result.confidence : 1 - result.confidence |
| } |
|
|
| function DeltaBadge({ before, after }) { |
| if (before == null || after == null) return null |
| const delta = after - before |
| const pct = Math.round(Math.abs(delta) * 100) |
| if (pct === 0) return <span className="text-xs text-gray-400 flex items-center gap-0.5"><Minus className="w-3 h-3" /> No change</span> |
| const improving = delta < 0 |
| return ( |
| <span className={`text-xs font-semibold flex items-center gap-0.5 ${improving ? 'text-green-600' : 'text-red-600'}`}> |
| {improving ? <TrendingDown className="w-3.5 h-3.5" /> : <TrendingUp className="w-3.5 h-3.5" />} |
| {improving ? '↓' : '↑'}{pct}% risk |
| </span> |
| ) |
| } |
|
|
| |
|
|
| function PatientPicker({ label, patients, selected, onSelect, color }) { |
| const [open, setOpen] = useState(false) |
|
|
| return ( |
| <div className="relative"> |
| <p className={`text-xs font-semibold uppercase tracking-wide mb-1 ${color}`}>{label}</p> |
| <button |
| onClick={() => setOpen(o => !o)} |
| className="w-full flex items-center justify-between gap-2 p-3 bg-gray-50 border border-clinical-border rounded-lg hover:bg-gray-100 transition-colors text-left" |
| > |
| <div className="flex items-center gap-2 min-w-0"> |
| <div className="w-8 h-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center text-xs font-bold flex-shrink-0"> |
| {selected?.avatar} |
| </div> |
| <div className="min-w-0"> |
| <p className="text-sm font-medium text-gray-900 truncate">{selected?.name ?? 'Select patient'}</p> |
| {selected && <p className="text-[10px] text-gray-500">{selected.age}yrs · {selected.sex}</p>} |
| </div> |
| </div> |
| <ChevronDown className={`w-4 h-4 text-gray-400 flex-shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} /> |
| </button> |
| {open && ( |
| <> |
| <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} /> |
| <div className="absolute top-full left-0 right-0 mt-1 bg-white border border-clinical-border rounded-lg shadow-lg z-20 overflow-hidden"> |
| {patients.map(p => ( |
| <button |
| key={p.id} |
| onClick={() => { onSelect(p); setOpen(false) }} |
| className={`w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-gray-50 transition-colors ${selected?.id === p.id ? 'bg-primary-50' : ''}`} |
| > |
| <div className="w-7 h-7 rounded-full bg-gray-100 text-gray-600 flex items-center justify-center text-xs font-bold flex-shrink-0"> |
| {p.avatar} |
| </div> |
| <div> |
| <p className="text-sm font-medium text-gray-900">{p.name}</p> |
| <p className="text-[10px] text-gray-500">{p.age}yrs · {p.sex}</p> |
| </div> |
| </button> |
| ))} |
| </div> |
| </> |
| )} |
| </div> |
| ) |
| } |
|
|
| |
|
|
| function ResultColumn({ label, result, loading, error, color, colorLight }) { |
| if (loading) return ( |
| <div className="flex-1 flex items-center justify-center py-12"> |
| <Loader2 className={`w-6 h-6 animate-spin ${color}`} /> |
| </div> |
| ) |
| if (error) return ( |
| <div className="flex-1 flex items-center gap-2 py-8 px-4"> |
| <AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" /> |
| <p className="text-xs text-red-600">{error}</p> |
| </div> |
| ) |
| if (!result) return ( |
| <div className="flex-1 flex items-center justify-center py-12 text-gray-300 text-sm"> |
| No result yet |
| </div> |
| ) |
|
|
| const isPositive = result.prediction === 1 |
| const conf = Math.round((result.confidence ?? 0) * 100) |
|
|
| return ( |
| <div className="flex-1 space-y-4 p-4"> |
| {/* Badge */} |
| <div className={`rounded-xl p-4 text-center ${isPositive ? 'bg-red-50 border border-red-200' : 'bg-green-50 border border-green-200'}`}> |
| <div className="flex items-center justify-center gap-2 mb-2"> |
| {isPositive |
| ? <XCircle className="w-5 h-5 text-red-600" /> |
| : <CheckCircle2 className="w-5 h-5 text-green-600" />} |
| <span className={`text-base font-bold ${isPositive ? 'text-red-700' : 'text-green-700'}`}> |
| {result.diagnosis ?? (isPositive ? 'Positive' : 'Negative')} |
| </span> |
| </div> |
| <p className="text-xs text-gray-500">Confidence</p> |
| <p className={`text-2xl font-black ${isPositive ? 'text-red-600' : 'text-green-600'}`}>{conf}%</p> |
| <div className="mt-2 h-2 bg-gray-200 rounded-full overflow-hidden"> |
| <div |
| className={`h-full rounded-full ${isPositive ? 'bg-red-500' : 'bg-green-500'}`} |
| style={{ width: `${conf}%` }} |
| /> |
| </div> |
| </div> |
| |
| {/* Risk score */} |
| <div className="text-center"> |
| <p className="text-xs text-gray-500 uppercase tracking-wide">Risk Score</p> |
| <p className={`text-3xl font-black mt-1 ${colorLight}`}> |
| {Math.round(riskScore(result) * 100)}% |
| </p> |
| </div> |
| </div> |
| ) |
| } |
|
|
| |
|
|
| function FeatureDiffTable({ beforePatient, afterPatient, fields }) { |
| if (!beforePatient || !afterPatient) return null |
|
|
| const b = beforePatient.data || {} |
| const a = afterPatient.data || {} |
|
|
| const diffs = fields |
| .filter(f => String(b[f.name]) !== String(a[f.name])) |
| .map(f => ({ name: f.name, label: f.label || f.name, before: b[f.name], after: a[f.name] })) |
|
|
| if (diffs.length === 0) { |
| return ( |
| <div className="text-center py-6 text-sm text-gray-400"> |
| Both patients have identical feature values. |
| </div> |
| ) |
| } |
|
|
| return ( |
| <div className="overflow-x-auto rounded-xl border border-clinical-border"> |
| <table className="w-full text-xs"> |
| <thead className="bg-slate-50"> |
| <tr> |
| <th className="text-left px-4 py-2.5 text-gray-500 font-medium uppercase tracking-wide text-[10px]">Feature</th> |
| <th className="text-left px-4 py-2.5 text-blue-600 font-medium uppercase tracking-wide text-[10px]">Before</th> |
| <th className="text-left px-4 py-2.5 text-violet-600 font-medium uppercase tracking-wide text-[10px]">After</th> |
| <th className="text-left px-4 py-2.5 text-gray-500 font-medium uppercase tracking-wide text-[10px]">Change</th> |
| </tr> |
| </thead> |
| <tbody className="divide-y divide-clinical-border"> |
| {diffs.map(d => { |
| const numBefore = parseFloat(d.before) |
| const numAfter = parseFloat(d.after) |
| const isNumeric = !isNaN(numBefore) && !isNaN(numAfter) |
| const delta = isNumeric ? numAfter - numBefore : null |
| return ( |
| <tr key={d.name} className="hover:bg-slate-50 transition-colors"> |
| <td className="px-4 py-2.5 font-medium text-gray-800">{d.label}</td> |
| <td className="px-4 py-2.5 text-blue-700 font-mono">{String(d.before ?? '—')}</td> |
| <td className="px-4 py-2.5 text-violet-700 font-mono">{String(d.after ?? '—')}</td> |
| <td className="px-4 py-2.5"> |
| {delta != null ? ( |
| <span className={`font-semibold ${delta > 0 ? 'text-red-500' : delta < 0 ? 'text-green-600' : 'text-gray-400'}`}> |
| {delta > 0 ? `+${delta.toFixed(1)}` : delta < 0 ? delta.toFixed(1) : '='} |
| </span> |
| ) : ( |
| <span className="text-gray-400">{d.before} → {d.after}</span> |
| )} |
| </td> |
| </tr> |
| ) |
| })} |
| </tbody> |
| </table> |
| </div> |
| ) |
| } |
|
|
| |
|
|
| function ComparisonChart({ beforeResult, afterResult, beforeName, afterName }) { |
| if (!beforeResult || !afterResult) return null |
|
|
| const data = [ |
| { |
| name: 'Risk Score', |
| Before: Math.round(riskScore(beforeResult) * 100), |
| After: Math.round(riskScore(afterResult) * 100), |
| }, |
| { |
| name: 'Confidence', |
| Before: Math.round((beforeResult.confidence ?? 0) * 100), |
| After: Math.round((afterResult.confidence ?? 0) * 100), |
| }, |
| ] |
|
|
| return ( |
| <div className="card"> |
| <div className="card-header"> |
| <h3 className="text-sm font-semibold text-gray-900">Side-by-Side Metrics</h3> |
| </div> |
| <div className="card-body"> |
| <ResponsiveContainer width="100%" height={180}> |
| <BarChart data={data} margin={{ top: 4, right: 16, left: -20, bottom: 0 }}> |
| <CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" /> |
| <XAxis dataKey="name" tick={{ fontSize: 11 }} /> |
| <YAxis domain={[0, 100]} tickFormatter={v => `${v}%`} tick={{ fontSize: 10 }} /> |
| <Tooltip formatter={(v) => [`${v}%`]} /> |
| <Legend /> |
| <Bar name={beforeName} dataKey="Before" fill="#2563eb" radius={[4, 4, 0, 0]} /> |
| <Bar name={afterName} dataKey="After" fill="#7c3aed" radius={[4, 4, 0, 0]} /> |
| </BarChart> |
| </ResponsiveContainer> |
| </div> |
| </div> |
| ) |
| } |
|
|
| |
|
|
| export default function ComparisonMode() { |
| const { selectedDisease } = useDisease() |
| const { fields } = useDiseaseSchema(selectedDisease) |
| const patients = getPatientsForDisease(selectedDisease) |
|
|
| const [beforePatient, setBeforePatient] = useState(patients[0] ?? null) |
| const [afterPatient, setAfterPatient] = useState(patients[1] ?? patients[0] ?? null) |
| const [beforeResult, setBeforeResult] = useState(null) |
| const [afterResult, setAfterResult] = useState(null) |
| const [beforeLoading, setBeforeLoading] = useState(false) |
| const [afterLoading, setAfterLoading] = useState(false) |
| const [beforeError, setBeforeError] = useState(null) |
| const [afterError, setAfterError] = useState(null) |
| const [ran, setRan] = useState(false) |
|
|
| const runComparison = useCallback(async () => { |
| if (!beforePatient || !afterPatient || !selectedDisease) return |
| setRan(true) |
| setBeforeResult(null) |
| setAfterResult(null) |
| setBeforeError(null) |
| setAfterError(null) |
|
|
| setBeforeLoading(true) |
| setAfterLoading(true) |
|
|
| const [bRes, aRes] = await Promise.allSettled([ |
| api.predict(selectedDisease, beforePatient.data), |
| api.predict(selectedDisease, afterPatient.data), |
| ]) |
|
|
| setBeforeLoading(false) |
| setAfterLoading(false) |
|
|
| if (bRes.status === 'fulfilled') setBeforeResult(bRes.value) |
| else setBeforeError(bRes.reason?.message ?? 'Prediction failed') |
|
|
| if (aRes.status === 'fulfilled') setAfterResult(aRes.value) |
| else setAfterError(aRes.reason?.message ?? 'Prediction failed') |
| }, [beforePatient, afterPatient, selectedDisease]) |
|
|
| const riskBefore = riskScore(beforeResult) |
| const riskAfter = riskScore(afterResult) |
|
|
| return ( |
| <div className="space-y-6"> |
| {/* Header */} |
| <div className="flex items-center justify-between"> |
| <div> |
| <h2 className="text-lg font-bold text-gray-900 flex items-center gap-2"> |
| <ArrowLeftRight className="w-5 h-5 text-primary-600" /> Before / After Comparison |
| </h2> |
| <p className="text-sm text-gray-500 mt-0.5"> |
| Compare two patient profiles side by side to evaluate risk changes |
| </p> |
| </div> |
| </div> |
| |
| {/* Patient selectors + compare button */} |
| <div className="card"> |
| <div className="card-body"> |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 items-end"> |
| <PatientPicker |
| label="Before" |
| patients={patients} |
| selected={beforePatient} |
| onSelect={p => { setBeforePatient(p); setRan(false) }} |
| color="text-blue-600" |
| /> |
| <PatientPicker |
| label="After" |
| patients={patients} |
| selected={afterPatient} |
| onSelect={p => { setAfterPatient(p); setRan(false) }} |
| color="text-violet-600" |
| /> |
| </div> |
| <div className="mt-4 flex justify-center"> |
| <button |
| onClick={runComparison} |
| disabled={!beforePatient || !afterPatient || beforeLoading || afterLoading} |
| className="btn-primary text-sm flex items-center gap-2" |
| > |
| {(beforeLoading || afterLoading) |
| ? <Loader2 className="w-4 h-4 animate-spin" /> |
| : <ArrowLeftRight className="w-4 h-4" />} |
| {(beforeLoading || afterLoading) ? 'Running…' : 'Compare'} |
| </button> |
| </div> |
| </div> |
| </div> |
| |
| {/* Results panel */} |
| {ran && ( |
| <> |
| {/* Side-by-side result columns */} |
| <div className="card"> |
| <div className="card-header flex items-center justify-between"> |
| <h3 className="text-sm font-semibold text-gray-900">Prediction Results</h3> |
| {riskBefore != null && riskAfter != null && ( |
| <DeltaBadge before={riskBefore} after={riskAfter} /> |
| )} |
| </div> |
| <div className="flex divide-x divide-clinical-border"> |
| <div className="flex-1"> |
| <p className="text-xs font-semibold text-blue-600 uppercase tracking-wide text-center pt-4 px-4"> |
| Before — {beforePatient?.name} |
| </p> |
| <ResultColumn |
| label="Before" |
| result={beforeResult} |
| loading={beforeLoading} |
| error={beforeError} |
| color="text-blue-600" |
| colorLight="text-blue-700" |
| /> |
| </div> |
| <div className="flex-1"> |
| <p className="text-xs font-semibold text-violet-600 uppercase tracking-wide text-center pt-4 px-4"> |
| After — {afterPatient?.name} |
| </p> |
| <ResultColumn |
| label="After" |
| result={afterResult} |
| loading={afterLoading} |
| error={afterError} |
| color="text-violet-600" |
| colorLight="text-violet-700" |
| /> |
| </div> |
| </div> |
| </div> |
| |
| {/* Chart comparison */} |
| <ComparisonChart |
| beforeResult={beforeResult} |
| afterResult={afterResult} |
| beforeName={beforePatient?.name ?? 'Before'} |
| afterName={afterPatient?.name ?? 'After'} |
| /> |
| |
| {/* Feature diff table */} |
| <div className="card"> |
| <div className="card-header"> |
| <h3 className="text-sm font-semibold text-gray-900">Changed Features</h3> |
| </div> |
| <div className="card-body"> |
| <FeatureDiffTable |
| beforePatient={beforePatient} |
| afterPatient={afterPatient} |
| fields={fields ?? []} |
| /> |
| </div> |
| </div> |
| </> |
| )} |
|
|
| {} |
| {!ran && ( |
| <div className="flex flex-col items-center justify-center py-20 text-center"> |
| <ArrowLeftRight className="w-12 h-12 text-gray-200 mb-4" /> |
| <p className="text-gray-500 text-sm">Select two patients and click Compare to see the results.</p> |
| </div> |
| )} |
| </div> |
| ) |
| } |
|
|