Spaces:
Runtime error
Runtime error
File size: 8,100 Bytes
4cea933 1e77bf3 604d53b 8f83768 4cea933 8f83768 1e77bf3 8f83768 1e77bf3 8f83768 4cea933 1e77bf3 4cea933 1e77bf3 8f83768 1e77bf3 8f83768 4cea933 8f83768 1e77bf3 8f83768 1e77bf3 8f83768 604d53b 8f83768 604d53b 4cea933 8f83768 4cea933 8f83768 1e77bf3 8f83768 1e77bf3 8f83768 1e77bf3 8f83768 1e77bf3 8f83768 1e77bf3 8f83768 1e77bf3 8f83768 1e77bf3 8f83768 1e77bf3 8f83768 604d53b 8f83768 1e77bf3 8f83768 1e77bf3 8f83768 1e77bf3 8f83768 1e77bf3 8f83768 1e77bf3 8f83768 1e77bf3 8f83768 1e77bf3 8f83768 1e77bf3 8f83768 604d53b 8f83768 1e77bf3 8f83768 1e77bf3 8f83768 1e77bf3 8f83768 1e77bf3 8f83768 4cea933 8f83768 4cea933 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | "use client";
import { useEffect, useState, useMemo } from "react";
import { PatientVitals } from "@/lib/edge/types";
import { computeEdgeShap } from "@/lib/edge/explain";
import { TrendingUp, TrendingDown, ShieldCheck, AlertTriangle } from "lucide-react";
interface ShapChartProps {
patientData: PatientVitals | null;
}
interface ShapData {
feature_names: string[];
shap_values: number[];
base_value?: number;
}
export default function ShapChart({ patientData }: ShapChartProps) {
const [cloudShap, setCloudShap] = useState<ShapData | null>(null);
// Compute zero-cloud edge attributions synchronously whenever patientData changes
const localShap = useMemo(() => {
if (!patientData) return null;
return computeEdgeShap(patientData);
}, [patientData]);
// Attempt backend TreeSHAP API enhancement if configured
useEffect(() => {
if (!patientData || !process.env.NEXT_PUBLIC_API_URL) return;
let isCancelled = false;
fetch(`${process.env.NEXT_PUBLIC_API_URL}/explain`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patientData),
})
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.shap_values && !isCancelled) {
setCloudShap(data);
}
})
.catch(() => {});
return () => {
isCancelled = true;
};
}, [patientData]);
const shapData = cloudShap || localShap;
if (!patientData || !shapData) {
return (
<div className="h-full min-h-60 w-full flex flex-col items-center justify-center p-8 text-center text-slate">
<span className="text-[12px] font-bold uppercase tracking-widest border border-ink/20 px-4 py-2 bg-canvas-cream">
Awaiting Input · Run Inference to Generate Attributions
</span>
</div>
);
}
const units: Record<string, string> = {
Heart_Rate: "BPM",
SpO2_Level: "%",
Systolic_BP: "mmHg",
Diastolic_BP: "mmHg",
Body_Temp: "°C",
};
const featureLabels: Record<string, string> = {
Heart_Rate: "HEART RATE",
SpO2_Level: "SPO2 LEVEL",
Systolic_BP: "SYSTOLIC BP",
Diastolic_BP: "DIASTOLIC BP",
Body_Temp: "BODY TEMP",
};
const items = shapData.feature_names
.map((rawName, i) => {
const val = shapData.shap_values[i];
const patientVal = patientData[rawName as keyof PatientVitals];
return {
key: rawName,
name: featureLabels[rawName] || rawName.toUpperCase(),
value: val,
abs: Math.abs(val),
isPositive: val > 0,
patientVal: patientVal,
unit: units[rawName] || "",
};
})
.sort((a, b) => b.abs - a.abs);
const maxAbs = Math.max(...items.map((i) => i.abs), 0.3);
const netShap = items.reduce((acc, curr) => acc + curr.value, 0);
const riskCount = items.filter((i) => i.isPositive).length;
const safeCount = items.filter((i) => !i.isPositive).length;
return (
<div className="flex flex-col gap-5 w-full h-full justify-between font-mono">
{/* SHAP Sign & Polarity Summary */}
<div className="border-2 border-ink bg-canvas-cream p-3 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2">
<div className="flex items-center gap-2">
{netShap > 0 ? (
<span className="flex items-center gap-1.5 px-2.5 py-1 text-[11px] font-black uppercase bg-hazard text-white border border-ink">
<AlertTriangle size={13} />
<span>NET RISK ATTRIBUTION: +{netShap.toFixed(3)}</span>
</span>
) : (
<span className="flex items-center gap-1.5 px-2.5 py-1 text-[11px] font-black uppercase bg-emerald-800 text-white border border-ink">
<ShieldCheck size={13} />
<span>NET PROTECTIVE ATTRIBUTION: {netShap.toFixed(3)}</span>
</span>
)}
</div>
<div className="flex items-center gap-3 text-[10px] font-bold uppercase tracking-wider">
<span className="text-hazard flex items-center gap-1">
<span className="inline-block w-2 h-2 bg-hazard" />
<span>{riskCount} RISK FACTORS</span>
</span>
<span className="text-emerald-800 flex items-center gap-1">
<span className="inline-block w-2 h-2 bg-emerald-700" />
<span>{safeCount} PROTECTIVE</span>
</span>
</div>
</div>
{/* Axis Polarity Legend */}
<div className="grid grid-cols-2 text-[10px] font-bold tracking-widest uppercase border-b-2 border-ink pb-2">
<div className="flex items-center gap-1.5 text-emerald-800">
<TrendingDown size={14} className="text-emerald-700" />
<span>PROTECTIVE (SUPPRESSES RISK)</span>
</div>
<div className="flex items-center justify-end gap-1.5 text-hazard">
<span>RISK FACTOR (DRIVES ANOMALY)</span>
<TrendingUp size={14} className="text-hazard" />
</div>
</div>
{/* Feature Attributions with Calibrated Midline Bars */}
<div className="flex flex-col gap-4">
{items.map((item) => {
const widthPercent = Math.min((item.abs / maxAbs) * 50, 48);
return (
<div key={item.key} className="flex flex-col gap-1.5">
{/* Header row: Feature Name + Patient Vitals + Signed Attribution */}
<div className="flex justify-between items-baseline text-[11px] font-bold">
<div className="flex items-center gap-2">
<span className="text-ink font-black">{item.name}</span>
<span className="text-slate text-[10px] bg-white border border-ink/30 px-1.5 py-0.2">
{item.patientVal} {item.unit}
</span>
</div>
<div className="flex items-center gap-1">
{item.isPositive ? (
<span className="px-2 py-0.5 text-[10px] font-black bg-hazard text-white border border-ink">
+{item.value.toFixed(3)}
</span>
) : (
<span className="px-2 py-0.5 text-[10px] font-black bg-emerald-100 text-emerald-900 border border-emerald-700">
{item.value.toFixed(3)}
</span>
)}
</div>
</div>
{/* Dual-Polarity Divergent Chart Bar with Centered 0.00 Midline */}
<div className="relative h-6 w-full bg-canvas-cream border-2 border-ink flex items-center overflow-hidden">
{/* Background Calibration Grid */}
<div
className="absolute inset-0 opacity-15 pointer-events-none"
style={{
backgroundImage: "linear-gradient(90deg, var(--ink-black) 1px, transparent 1px)",
backgroundSize: "20% 100%",
}}
/>
{/* Left/Right Polarity Background Shading */}
<div className="absolute left-0 top-0 bottom-0 w-1/2 bg-emerald-500/5 pointer-events-none" />
<div className="absolute right-0 top-0 bottom-0 w-1/2 bg-hazard/5 pointer-events-none" />
{/* Midline (0.00 Zero-Axis) */}
<div className="absolute left-1/2 -top-1 -bottom-1 w-0.5 bg-ink z-20" />
{/* Responsive Dual-Polarity Bar */}
{item.isPositive ? (
<div
className="absolute top-0 bottom-0 left-1/2 bg-hazard z-10 h-full transition-all duration-150 border-r border-ink"
style={{ width: `${widthPercent}%` }}
/>
) : (
<div
className="absolute top-0 bottom-0 right-1/2 bg-emerald-700 z-10 h-full transition-all duration-150 border-l border-ink"
style={{ width: `${widthPercent}%` }}
/>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
|