"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(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 (
Awaiting Input · Run Inference to Generate Attributions
); } const units: Record = { Heart_Rate: "BPM", SpO2_Level: "%", Systolic_BP: "mmHg", Diastolic_BP: "mmHg", Body_Temp: "°C", }; const featureLabels: Record = { 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 (
{/* SHAP Sign & Polarity Summary */}
{netShap > 0 ? ( NET RISK ATTRIBUTION: +{netShap.toFixed(3)} ) : ( NET PROTECTIVE ATTRIBUTION: {netShap.toFixed(3)} )}
{riskCount} RISK FACTORS {safeCount} PROTECTIVE
{/* Axis Polarity Legend */}
PROTECTIVE (SUPPRESSES RISK)
RISK FACTOR (DRIVES ANOMALY)
{/* Feature Attributions with Calibrated Midline Bars */}
{items.map((item) => { const widthPercent = Math.min((item.abs / maxAbs) * 50, 48); return (
{/* Header row: Feature Name + Patient Vitals + Signed Attribution */}
{item.name} {item.patientVal} {item.unit}
{item.isPositive ? ( +{item.value.toFixed(3)} ) : ( {item.value.toFixed(3)} )}
{/* Dual-Polarity Divergent Chart Bar with Centered 0.00 Midline */}
{/* Background Calibration Grid */}
{/* Left/Right Polarity Background Shading */}
{/* Midline (0.00 Zero-Axis) */}
{/* Responsive Dual-Polarity Bar */} {item.isPositive ? (
) : (
)}
); })}
); }