Spaces:
Runtime error
Runtime error
Upload folder using huggingface_hub
Browse files- frontend/src/app/simulator/page.tsx +243 -64
- frontend/src/components/MermaidChart.tsx +1 -1
- frontend/src/components/ShapChart.tsx +178 -121
- frontend/src/lib/edge/explain.ts +71 -20
frontend/src/app/simulator/page.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useState } from "react";
|
| 4 |
-
import { Sliders, Activity, Cpu } from "lucide-react";
|
| 5 |
import ShapChart from "@/components/ShapChart";
|
| 6 |
import { PatientVitals } from "@/lib/edge/types";
|
| 7 |
import { evaluateEdge } from "@/lib/edge/inference";
|
|
@@ -15,6 +15,25 @@ interface PredictionResult {
|
|
| 15 |
weights?: Record<string, number>;
|
| 16 |
}
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
const DEFAULT_VITALS: PatientVitals = {
|
| 19 |
Heart_Rate: 75,
|
| 20 |
SpO2_Level: 98,
|
|
@@ -38,6 +57,45 @@ export default function Analysis() {
|
|
| 38 |
};
|
| 39 |
});
|
| 40 |
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
const handlePredict = async (inputData?: PatientVitals) => {
|
| 43 |
const targetData = inputData || formData;
|
|
@@ -48,9 +106,9 @@ export default function Analysis() {
|
|
| 48 |
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
| 49 |
if (apiUrl) {
|
| 50 |
const res = await fetch(`${apiUrl}/predict`, {
|
| 51 |
-
method:
|
| 52 |
-
headers: {
|
| 53 |
-
body: JSON.stringify(targetData)
|
| 54 |
});
|
| 55 |
if (res.ok) {
|
| 56 |
const data = await res.json();
|
|
@@ -65,16 +123,7 @@ export default function Analysis() {
|
|
| 65 |
|
| 66 |
// Client-side Zero-Cloud TinyML Ensemble Inference
|
| 67 |
try {
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
setPrediction({
|
| 71 |
-
prediction: Boolean(edgeResult.is_at_risk),
|
| 72 |
-
prediction_label: edgeResult.label,
|
| 73 |
-
probability: edgeResult.confidence,
|
| 74 |
-
model_outputs: edgeResult.model_outputs,
|
| 75 |
-
model_probs: edgeResult.model_probs,
|
| 76 |
-
weights: edgeResult.weights,
|
| 77 |
-
});
|
| 78 |
} catch (e) {
|
| 79 |
console.error("Local inference error", e);
|
| 80 |
} finally {
|
|
@@ -82,19 +131,27 @@ export default function Analysis() {
|
|
| 82 |
}
|
| 83 |
};
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
const handleSelectPreset = (presetData: PatientVitals) => {
|
| 86 |
setFormData({ ...presetData });
|
| 87 |
-
|
| 88 |
};
|
| 89 |
|
| 90 |
return (
|
| 91 |
<main className="min-h-dvh bg-canvas-cream flex flex-col font-mono selection:bg-hazard selection:text-white pb-16">
|
| 92 |
<div className="max-w-350 mx-auto w-full px-4 sm:px-8 py-10 md:py-12 flex-1 flex flex-col">
|
|
|
|
| 93 |
<header className="mb-8 border-b-4 border-ink pb-8 flex flex-col md:flex-row justify-between md:items-end gap-6">
|
| 94 |
<div>
|
| 95 |
<div className="text-[12px] text-hazard font-bold tracking-widest mb-4 flex items-center gap-2 uppercase">
|
| 96 |
<Sliders size={14} />
|
| 97 |
-
MODEL EXPLAINABILITY & SIMULATION BENCH
|
| 98 |
</div>
|
| 99 |
<h1 className="text-[clamp(2.5rem,5vw,5.5rem)] leading-[0.9]">
|
| 100 |
MODEL<br />SIMULATOR
|
|
@@ -105,13 +162,54 @@ export default function Analysis() {
|
|
| 105 |
ENSEMBLE: 5 MODELS · SHAP EXPLAINABILITY
|
| 106 |
</div>
|
| 107 |
<div className="text-[11px] text-slate font-bold uppercase tracking-wider">
|
| 108 |
-
Deterministic In-Browser Inference ·
|
| 109 |
</div>
|
| 110 |
</div>
|
| 111 |
</header>
|
| 112 |
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
{/* Input Panel - 6 Columns */}
|
| 116 |
<div className="lg:col-span-6 bg-canvas-cream flex flex-col justify-between">
|
| 117 |
<div>
|
|
@@ -120,10 +218,20 @@ export default function Analysis() {
|
|
| 120 |
<Activity size={16} />
|
| 121 |
Patient Parameters
|
| 122 |
</h2>
|
| 123 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
</div>
|
| 125 |
|
| 126 |
-
{/* Quick Clinical Test Vector Presets
|
| 127 |
<div className="p-5 sm:p-6 bg-canvas-cream border-b-2 border-ink flex flex-col gap-3">
|
| 128 |
<div className="text-[11px] font-bold tracking-widest text-slate uppercase flex justify-between items-center">
|
| 129 |
<span className="text-ink">Clinical Patient Profiles</span>
|
|
@@ -135,26 +243,26 @@ export default function Analysis() {
|
|
| 135 |
name: "Resting Normal",
|
| 136 |
tag: "NORMAL",
|
| 137 |
desc: "Normotensive baseline vitals",
|
| 138 |
-
data: { Heart_Rate: 72, SpO2_Level: 98, Systolic_BP: 118, Diastolic_BP: 78, Body_Temp: 37.0 }
|
| 139 |
},
|
| 140 |
{
|
| 141 |
name: "STEMI / Ischemia",
|
| 142 |
tag: "CRITICAL",
|
| 143 |
desc: "Acute ischemic anomaly",
|
| 144 |
-
data: { Heart_Rate: 128, SpO2_Level: 91, Systolic_BP: 165, Diastolic_BP: 104, Body_Temp: 37.3 }
|
| 145 |
},
|
| 146 |
{
|
| 147 |
name: "Hypoxic Bradycardia",
|
| 148 |
tag: "WARNING",
|
| 149 |
desc: "Hypoxemic rhythm depression",
|
| 150 |
-
data: { Heart_Rate: 48, SpO2_Level: 82, Systolic_BP: 88, Diastolic_BP: 54, Body_Temp: 35.8 }
|
| 151 |
},
|
| 152 |
{
|
| 153 |
name: "Hypertensive Crisis",
|
| 154 |
tag: "WARNING",
|
| 155 |
desc: "Acute arterial pressure surge",
|
| 156 |
-
data: { Heart_Rate: 98, SpO2_Level: 96, Systolic_BP: 195, Diastolic_BP: 118, Body_Temp: 37.1 }
|
| 157 |
-
}
|
| 158 |
].map((preset) => {
|
| 159 |
const isActive =
|
| 160 |
formData.Heart_Rate === preset.data.Heart_Rate &&
|
|
@@ -202,24 +310,70 @@ export default function Analysis() {
|
|
| 202 |
</div>
|
| 203 |
</div>
|
| 204 |
|
| 205 |
-
{/* Slider Inputs */}
|
| 206 |
<div className="p-6 sm:p-8 flex flex-col gap-6">
|
| 207 |
-
<InputControl
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
</div>
|
| 213 |
</div>
|
| 214 |
|
| 215 |
-
<div className="p-6 sm:p-8 pt-0">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
<button
|
| 217 |
type="button"
|
| 218 |
onClick={() => handlePredict()}
|
| 219 |
disabled={loading}
|
| 220 |
-
className="btn-tactile-hazard py-
|
| 221 |
>
|
| 222 |
-
{loading ?
|
| 223 |
</button>
|
| 224 |
</div>
|
| 225 |
</div>
|
|
@@ -229,48 +383,66 @@ export default function Analysis() {
|
|
| 229 |
<div className="h-14 px-4 sm:px-6 border-b-2 border-ink bg-white flex justify-between items-center">
|
| 230 |
<h2 className="text-[14px] font-bold tracking-wider uppercase flex items-center gap-2">
|
| 231 |
<Cpu size={16} />
|
| 232 |
-
Classification & SHAP Attributions
|
| 233 |
</h2>
|
| 234 |
<span className="text-[10px] text-slate uppercase font-bold">Ensemble Explainability</span>
|
| 235 |
</div>
|
| 236 |
|
| 237 |
<div className="grow flex flex-col relative bg-white">
|
| 238 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
|
| 240 |
-
<div className="z-10 p-8 flex flex-col h-full">
|
| 241 |
{prediction ? (
|
| 242 |
-
<
|
| 243 |
-
<div
|
| 244 |
-
<div className="text-[11px] font-bold text-slate tracking-widest mb-
|
|
|
|
|
|
|
| 245 |
|
| 246 |
{(() => {
|
| 247 |
const getStatusStyles = (label: string) => {
|
| 248 |
const normalized = (label || "").toLowerCase();
|
| 249 |
-
if (normalized ===
|
| 250 |
-
|
| 251 |
-
if (normalized.includes(
|
| 252 |
-
|
| 253 |
-
if (normalized.includes(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
return "bg-hazard text-white border-hazard";
|
| 255 |
};
|
| 256 |
|
| 257 |
return (
|
| 258 |
-
<div
|
| 259 |
-
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
</div>
|
| 262 |
-
<div className="text-[
|
| 263 |
CONFIDENCE: {(prediction.probability * 100).toFixed(1)}%
|
| 264 |
</div>
|
| 265 |
</div>
|
| 266 |
);
|
| 267 |
})()}
|
| 268 |
|
| 269 |
-
{/* Ensemble Diagnostics */}
|
| 270 |
{prediction.model_outputs && (() => {
|
| 271 |
const outputs = prediction.model_outputs;
|
| 272 |
return (
|
| 273 |
-
<div className="border-2 border-ink bg-white flex flex-col text-[
|
| 274 |
<div className="border-b-2 border-ink px-4 py-2 bg-ink text-canvas-cream flex justify-between">
|
| 275 |
<span>Ensemble Stack Diagnostics</span>
|
| 276 |
<span className="text-hazard">Weighted Voting</span>
|
|
@@ -279,16 +451,20 @@ export default function Analysis() {
|
|
| 279 |
{Object.keys(outputs).map((modelName) => (
|
| 280 |
<div
|
| 281 |
key={modelName}
|
| 282 |
-
className="flex justify-between items-center p-
|
| 283 |
>
|
| 284 |
-
<div className="w-[
|
| 285 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
{outputs[modelName]}
|
| 287 |
</div>
|
| 288 |
-
<div className="w-[20%] text-right">
|
| 289 |
-
|
| 290 |
</div>
|
| 291 |
-
<div className="w-[20%] text-right text-slate">
|
| 292 |
W: {(prediction.weights?.[modelName] ?? 0.25).toFixed(2)}
|
| 293 |
</div>
|
| 294 |
</div>
|
|
@@ -299,24 +475,27 @@ export default function Analysis() {
|
|
| 299 |
})()}
|
| 300 |
</div>
|
| 301 |
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
<div className="flex-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
<ShapChart patientData={submittedData} />
|
| 306 |
</div>
|
| 307 |
</div>
|
| 308 |
-
</
|
| 309 |
) : (
|
| 310 |
<div className="h-full flex items-center justify-center">
|
| 311 |
<div className="text-slate text-[14px] font-bold tracking-widest uppercase border-2 border-slate px-6 py-4">
|
| 312 |
-
{loading ?
|
| 313 |
</div>
|
| 314 |
</div>
|
| 315 |
)}
|
| 316 |
</div>
|
| 317 |
</div>
|
| 318 |
</div>
|
| 319 |
-
|
| 320 |
</div>
|
| 321 |
</div>
|
| 322 |
</main>
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useState, useEffect, useCallback } from "react";
|
| 4 |
+
import { Sliders, Activity, Cpu, Shuffle, Play, Square, Sparkles } from "lucide-react";
|
| 5 |
import ShapChart from "@/components/ShapChart";
|
| 6 |
import { PatientVitals } from "@/lib/edge/types";
|
| 7 |
import { evaluateEdge } from "@/lib/edge/inference";
|
|
|
|
| 15 |
weights?: Record<string, number>;
|
| 16 |
}
|
| 17 |
|
| 18 |
+
// Clinically bounded randomized vitals generator
|
| 19 |
+
function generateRandomVitals(): PatientVitals {
|
| 20 |
+
const hr = Math.floor(Math.random() * (162 - 48 + 1)) + 48; // 48 - 162 BPM
|
| 21 |
+
const spo2 = Math.floor(Math.random() * (99 - 78 + 1)) + 78; // 78 - 99 %
|
| 22 |
+
const sys = Math.floor(Math.random() * (190 - 86 + 1)) + 86; // 86 - 190 mmHg
|
| 23 |
+
const diaMax = Math.min(sys - 18, 120);
|
| 24 |
+
const diaMin = Math.max(Math.min(diaMax - 14, 52), 46);
|
| 25 |
+
const dia = Math.floor(Math.random() * (diaMax - diaMin + 1)) + diaMin;
|
| 26 |
+
const temp = Number((Math.random() * (39.3 - 35.8) + 35.8).toFixed(1));
|
| 27 |
+
|
| 28 |
+
return {
|
| 29 |
+
Heart_Rate: hr,
|
| 30 |
+
SpO2_Level: spo2,
|
| 31 |
+
Systolic_BP: sys,
|
| 32 |
+
Diastolic_BP: dia,
|
| 33 |
+
Body_Temp: temp,
|
| 34 |
+
};
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
const DEFAULT_VITALS: PatientVitals = {
|
| 38 |
Heart_Rate: 75,
|
| 39 |
SpO2_Level: 98,
|
|
|
|
| 57 |
};
|
| 58 |
});
|
| 59 |
const [loading, setLoading] = useState(false);
|
| 60 |
+
const [autoStream, setAutoStream] = useState(false);
|
| 61 |
+
|
| 62 |
+
// Core evaluation function
|
| 63 |
+
const executeEvaluation = useCallback((data: PatientVitals) => {
|
| 64 |
+
const edgeResult = evaluateEdge(data);
|
| 65 |
+
setSubmittedData({ ...data });
|
| 66 |
+
setPrediction({
|
| 67 |
+
prediction: Boolean(edgeResult.is_at_risk),
|
| 68 |
+
prediction_label: edgeResult.label,
|
| 69 |
+
probability: edgeResult.confidence,
|
| 70 |
+
model_outputs: edgeResult.model_outputs,
|
| 71 |
+
model_probs: edgeResult.model_probs,
|
| 72 |
+
weights: edgeResult.weights,
|
| 73 |
+
});
|
| 74 |
+
}, []);
|
| 75 |
+
|
| 76 |
+
// Handler to throw randomized values for all features
|
| 77 |
+
const handleThrowRandomValues = useCallback(() => {
|
| 78 |
+
const randomVitals = generateRandomVitals();
|
| 79 |
+
setFormData(randomVitals);
|
| 80 |
+
executeEvaluation(randomVitals);
|
| 81 |
+
}, [executeEvaluation]);
|
| 82 |
+
|
| 83 |
+
// Throw initial random values on mount so the simulator is always dynamic
|
| 84 |
+
useEffect(() => {
|
| 85 |
+
const timer = setTimeout(() => {
|
| 86 |
+
handleThrowRandomValues();
|
| 87 |
+
}, 50);
|
| 88 |
+
return () => clearTimeout(timer);
|
| 89 |
+
}, [handleThrowRandomValues]);
|
| 90 |
+
|
| 91 |
+
// Continuous auto-streaming simulation mode
|
| 92 |
+
useEffect(() => {
|
| 93 |
+
if (!autoStream) return;
|
| 94 |
+
const interval = setInterval(() => {
|
| 95 |
+
handleThrowRandomValues();
|
| 96 |
+
}, 2400);
|
| 97 |
+
return () => clearInterval(interval);
|
| 98 |
+
}, [autoStream, handleThrowRandomValues]);
|
| 99 |
|
| 100 |
const handlePredict = async (inputData?: PatientVitals) => {
|
| 101 |
const targetData = inputData || formData;
|
|
|
|
| 106 |
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
| 107 |
if (apiUrl) {
|
| 108 |
const res = await fetch(`${apiUrl}/predict`, {
|
| 109 |
+
method: "POST",
|
| 110 |
+
headers: { "Content-Type": "application/json" },
|
| 111 |
+
body: JSON.stringify(targetData),
|
| 112 |
});
|
| 113 |
if (res.ok) {
|
| 114 |
const data = await res.json();
|
|
|
|
| 123 |
|
| 124 |
// Client-side Zero-Cloud TinyML Ensemble Inference
|
| 125 |
try {
|
| 126 |
+
executeEvaluation(targetData);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
} catch (e) {
|
| 128 |
console.error("Local inference error", e);
|
| 129 |
} finally {
|
|
|
|
| 131 |
}
|
| 132 |
};
|
| 133 |
|
| 134 |
+
// Real-time reactive slider adjustment
|
| 135 |
+
const handleSliderChange = (field: keyof PatientVitals, val: number) => {
|
| 136 |
+
const updated = { ...formData, [field]: val };
|
| 137 |
+
setFormData(updated);
|
| 138 |
+
executeEvaluation(updated);
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
const handleSelectPreset = (presetData: PatientVitals) => {
|
| 142 |
setFormData({ ...presetData });
|
| 143 |
+
executeEvaluation(presetData);
|
| 144 |
};
|
| 145 |
|
| 146 |
return (
|
| 147 |
<main className="min-h-dvh bg-canvas-cream flex flex-col font-mono selection:bg-hazard selection:text-white pb-16">
|
| 148 |
<div className="max-w-350 mx-auto w-full px-4 sm:px-8 py-10 md:py-12 flex-1 flex flex-col">
|
| 149 |
+
{/* Monolithic Header */}
|
| 150 |
<header className="mb-8 border-b-4 border-ink pb-8 flex flex-col md:flex-row justify-between md:items-end gap-6">
|
| 151 |
<div>
|
| 152 |
<div className="text-[12px] text-hazard font-bold tracking-widest mb-4 flex items-center gap-2 uppercase">
|
| 153 |
<Sliders size={14} />
|
| 154 |
+
MODEL EXPLAINABILITY & SIMULATION BENCH
|
| 155 |
</div>
|
| 156 |
<h1 className="text-[clamp(2.5rem,5vw,5.5rem)] leading-[0.9]">
|
| 157 |
MODEL<br />SIMULATOR
|
|
|
|
| 162 |
ENSEMBLE: 5 MODELS · SHAP EXPLAINABILITY
|
| 163 |
</div>
|
| 164 |
<div className="text-[11px] text-slate font-bold uppercase tracking-wider">
|
| 165 |
+
Deterministic In-Browser Inference · Dual-Polarity SHAP Signs
|
| 166 |
</div>
|
| 167 |
</div>
|
| 168 |
</header>
|
| 169 |
|
| 170 |
+
{/* Global Control Bar for Randomization */}
|
| 171 |
+
<div className="mb-6 border-2 border-ink bg-white p-4 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 shadow-[4px_4px_0px_0px_#141413]">
|
| 172 |
+
<div className="flex items-center gap-2">
|
| 173 |
+
<span className="w-2.5 h-2.5 rounded-full bg-hazard" />
|
| 174 |
+
<span className="text-[12px] font-black uppercase tracking-wider text-ink">
|
| 175 |
+
TELEMETRY GENERATOR
|
| 176 |
+
</span>
|
| 177 |
+
<span className="text-[10px] font-bold text-slate uppercase bg-canvas-cream border border-ink/30 px-2 py-0.5 hidden sm:inline">
|
| 178 |
+
Random Physiologic Vector Sampling
|
| 179 |
+
</span>
|
| 180 |
+
</div>
|
| 181 |
|
| 182 |
+
<div className="flex items-center gap-2 w-full sm:w-auto">
|
| 183 |
+
{/* Throw Random Values Button */}
|
| 184 |
+
<button
|
| 185 |
+
type="button"
|
| 186 |
+
onClick={handleThrowRandomValues}
|
| 187 |
+
className="btn-tactile border-2 border-ink bg-hazard text-white hover:bg-black px-4 py-2 text-[12px] font-black uppercase tracking-wider flex items-center justify-center gap-2 transition-colors cursor-pointer flex-1 sm:flex-none"
|
| 188 |
+
title="Throw randomized physiological vitals"
|
| 189 |
+
>
|
| 190 |
+
<Shuffle size={14} />
|
| 191 |
+
<span>Throw Random Values</span>
|
| 192 |
+
</button>
|
| 193 |
+
|
| 194 |
+
{/* Auto Stream Toggle */}
|
| 195 |
+
<button
|
| 196 |
+
type="button"
|
| 197 |
+
onClick={() => setAutoStream(!autoStream)}
|
| 198 |
+
className={`border-2 border-ink px-3 py-2 text-[12px] font-black uppercase tracking-wider flex items-center gap-1.5 transition-colors cursor-pointer ${
|
| 199 |
+
autoStream
|
| 200 |
+
? "bg-emerald-600 text-white border-emerald-700 animate-pulse"
|
| 201 |
+
: "bg-white text-ink hover:bg-canvas-cream"
|
| 202 |
+
}`}
|
| 203 |
+
title="Continuously throw random vitals every 2.4 seconds"
|
| 204 |
+
>
|
| 205 |
+
{autoStream ? <Square size={13} /> : <Play size={13} />}
|
| 206 |
+
<span>{autoStream ? "Streaming (Active)" : "Auto-Stream"}</span>
|
| 207 |
+
</button>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
{/* Main Workbench Grid */}
|
| 212 |
+
<div className="crosshair-corner grid grid-cols-1 lg:grid-cols-12 gap-px bg-ink border-2 border-ink items-stretch">
|
| 213 |
{/* Input Panel - 6 Columns */}
|
| 214 |
<div className="lg:col-span-6 bg-canvas-cream flex flex-col justify-between">
|
| 215 |
<div>
|
|
|
|
| 218 |
<Activity size={16} />
|
| 219 |
Patient Parameters
|
| 220 |
</h2>
|
| 221 |
+
<div className="flex items-center gap-2">
|
| 222 |
+
<button
|
| 223 |
+
type="button"
|
| 224 |
+
onClick={handleThrowRandomValues}
|
| 225 |
+
className="text-[10px] font-black uppercase tracking-wider text-hazard border border-hazard px-2 py-0.5 hover:bg-hazard hover:text-white transition-colors cursor-pointer flex items-center gap-1"
|
| 226 |
+
>
|
| 227 |
+
<Sparkles size={11} />
|
| 228 |
+
<span>Randomize</span>
|
| 229 |
+
</button>
|
| 230 |
+
<span className="text-[10px] text-slate uppercase font-bold">5 Telemetry Inputs</span>
|
| 231 |
+
</div>
|
| 232 |
</div>
|
| 233 |
|
| 234 |
+
{/* Quick Clinical Test Vector Presets */}
|
| 235 |
<div className="p-5 sm:p-6 bg-canvas-cream border-b-2 border-ink flex flex-col gap-3">
|
| 236 |
<div className="text-[11px] font-bold tracking-widest text-slate uppercase flex justify-between items-center">
|
| 237 |
<span className="text-ink">Clinical Patient Profiles</span>
|
|
|
|
| 243 |
name: "Resting Normal",
|
| 244 |
tag: "NORMAL",
|
| 245 |
desc: "Normotensive baseline vitals",
|
| 246 |
+
data: { Heart_Rate: 72, SpO2_Level: 98, Systolic_BP: 118, Diastolic_BP: 78, Body_Temp: 37.0 },
|
| 247 |
},
|
| 248 |
{
|
| 249 |
name: "STEMI / Ischemia",
|
| 250 |
tag: "CRITICAL",
|
| 251 |
desc: "Acute ischemic anomaly",
|
| 252 |
+
data: { Heart_Rate: 128, SpO2_Level: 91, Systolic_BP: 165, Diastolic_BP: 104, Body_Temp: 37.3 },
|
| 253 |
},
|
| 254 |
{
|
| 255 |
name: "Hypoxic Bradycardia",
|
| 256 |
tag: "WARNING",
|
| 257 |
desc: "Hypoxemic rhythm depression",
|
| 258 |
+
data: { Heart_Rate: 48, SpO2_Level: 82, Systolic_BP: 88, Diastolic_BP: 54, Body_Temp: 35.8 },
|
| 259 |
},
|
| 260 |
{
|
| 261 |
name: "Hypertensive Crisis",
|
| 262 |
tag: "WARNING",
|
| 263 |
desc: "Acute arterial pressure surge",
|
| 264 |
+
data: { Heart_Rate: 98, SpO2_Level: 96, Systolic_BP: 195, Diastolic_BP: 118, Body_Temp: 37.1 },
|
| 265 |
+
},
|
| 266 |
].map((preset) => {
|
| 267 |
const isActive =
|
| 268 |
formData.Heart_Rate === preset.data.Heart_Rate &&
|
|
|
|
| 310 |
</div>
|
| 311 |
</div>
|
| 312 |
|
| 313 |
+
{/* Slider Inputs with Live Scrubbing */}
|
| 314 |
<div className="p-6 sm:p-8 flex flex-col gap-6">
|
| 315 |
+
<InputControl
|
| 316 |
+
label="Heart Rate"
|
| 317 |
+
unit="BPM"
|
| 318 |
+
value={formData.Heart_Rate}
|
| 319 |
+
min={40}
|
| 320 |
+
max={200}
|
| 321 |
+
onChange={(v: number) => handleSliderChange("Heart_Rate", v)}
|
| 322 |
+
/>
|
| 323 |
+
<InputControl
|
| 324 |
+
label="SpO2 Level"
|
| 325 |
+
unit="%"
|
| 326 |
+
value={formData.SpO2_Level}
|
| 327 |
+
min={70}
|
| 328 |
+
max={100}
|
| 329 |
+
onChange={(v: number) => handleSliderChange("SpO2_Level", v)}
|
| 330 |
+
/>
|
| 331 |
+
<InputControl
|
| 332 |
+
label="Systolic BP"
|
| 333 |
+
unit="mmHg"
|
| 334 |
+
value={formData.Systolic_BP}
|
| 335 |
+
min={80}
|
| 336 |
+
max={220}
|
| 337 |
+
onChange={(v: number) => handleSliderChange("Systolic_BP", v)}
|
| 338 |
+
/>
|
| 339 |
+
<InputControl
|
| 340 |
+
label="Diastolic BP"
|
| 341 |
+
unit="mmHg"
|
| 342 |
+
value={formData.Diastolic_BP}
|
| 343 |
+
min={40}
|
| 344 |
+
max={140}
|
| 345 |
+
onChange={(v: number) => handleSliderChange("Diastolic_BP", v)}
|
| 346 |
+
/>
|
| 347 |
+
<InputControl
|
| 348 |
+
label="Body Temperature"
|
| 349 |
+
unit="°C"
|
| 350 |
+
value={formData.Body_Temp}
|
| 351 |
+
min={30}
|
| 352 |
+
max={42}
|
| 353 |
+
step={0.1}
|
| 354 |
+
onChange={(v: number) => handleSliderChange("Body_Temp", v)}
|
| 355 |
+
/>
|
| 356 |
</div>
|
| 357 |
</div>
|
| 358 |
|
| 359 |
+
<div className="p-6 sm:p-8 pt-0 flex gap-3">
|
| 360 |
+
<button
|
| 361 |
+
type="button"
|
| 362 |
+
onClick={handleThrowRandomValues}
|
| 363 |
+
className="btn-tactile py-4 px-5 border-2 border-ink bg-canvas-cream hover:bg-white text-ink font-bold text-[13px] tracking-wider uppercase flex items-center justify-center gap-2 cursor-pointer transition-colors"
|
| 364 |
+
title="Throw random physiological values"
|
| 365 |
+
>
|
| 366 |
+
<Shuffle size={14} className="text-hazard" />
|
| 367 |
+
<span>Randomize</span>
|
| 368 |
+
</button>
|
| 369 |
+
|
| 370 |
<button
|
| 371 |
type="button"
|
| 372 |
onClick={() => handlePredict()}
|
| 373 |
disabled={loading}
|
| 374 |
+
className="btn-tactile-hazard py-4 font-bold text-[14px] sm:text-[15px] tracking-[0.2em] uppercase flex items-center justify-center gap-2 flex-1 cursor-pointer disabled:cursor-not-allowed"
|
| 375 |
>
|
| 376 |
+
{loading ? "Evaluating Model..." : "Re-evaluate Model"}
|
| 377 |
</button>
|
| 378 |
</div>
|
| 379 |
</div>
|
|
|
|
| 383 |
<div className="h-14 px-4 sm:px-6 border-b-2 border-ink bg-white flex justify-between items-center">
|
| 384 |
<h2 className="text-[14px] font-bold tracking-wider uppercase flex items-center gap-2">
|
| 385 |
<Cpu size={16} />
|
| 386 |
+
Classification & SHAP Attributions
|
| 387 |
</h2>
|
| 388 |
<span className="text-[10px] text-slate uppercase font-bold">Ensemble Explainability</span>
|
| 389 |
</div>
|
| 390 |
|
| 391 |
<div className="grow flex flex-col relative bg-white">
|
| 392 |
+
<div
|
| 393 |
+
className="absolute inset-0 z-0 opacity-10 pointer-events-none"
|
| 394 |
+
style={{
|
| 395 |
+
backgroundImage:
|
| 396 |
+
"linear-gradient(var(--ink-black) 1px, transparent 1px), linear-gradient(90deg, var(--ink-black) 1px, transparent 1px)",
|
| 397 |
+
backgroundSize: "40px 40px",
|
| 398 |
+
}}
|
| 399 |
+
/>
|
| 400 |
|
| 401 |
+
<div className="z-10 p-6 sm:p-8 flex flex-col h-full gap-8">
|
| 402 |
{prediction ? (
|
| 403 |
+
<>
|
| 404 |
+
<div>
|
| 405 |
+
<div className="text-[11px] font-bold text-slate tracking-widest mb-3 uppercase">
|
| 406 |
+
Model Output & Diagnosis
|
| 407 |
+
</div>
|
| 408 |
|
| 409 |
{(() => {
|
| 410 |
const getStatusStyles = (label: string) => {
|
| 411 |
const normalized = (label || "").toLowerCase();
|
| 412 |
+
if (normalized === "healthy" || normalized === "normal")
|
| 413 |
+
return "bg-[#d1fae5] text-[#065f46] border-[#065f46]";
|
| 414 |
+
if (normalized.includes("heart"))
|
| 415 |
+
return "bg-[#fef08a] text-[#854d0e] border-[#854d0e]";
|
| 416 |
+
if (normalized.includes("asthma"))
|
| 417 |
+
return "bg-[#e0f2fe] text-[#075985] border-[#075985]";
|
| 418 |
+
if (normalized.includes("hypertension"))
|
| 419 |
+
return "bg-[#f3e8ff] text-[#6b21a8] border-[#6b21a8]";
|
| 420 |
+
if (normalized.includes("diabetes"))
|
| 421 |
+
return "bg-[#ffedd5] text-[#9a3412] border-[#9a3412]";
|
| 422 |
return "bg-hazard text-white border-hazard";
|
| 423 |
};
|
| 424 |
|
| 425 |
return (
|
| 426 |
+
<div
|
| 427 |
+
className={`border-2 p-5 sm:p-6 flex flex-col mb-4 ${getStatusStyles(
|
| 428 |
+
prediction.prediction ? prediction.prediction_label : "Healthy"
|
| 429 |
+
)}`}
|
| 430 |
+
>
|
| 431 |
+
<div className="font-sans text-[clamp(28px,3vw,52px)] font-black tracking-tighter leading-none mb-2 uppercase wrap-break-words">
|
| 432 |
+
{prediction.prediction ? prediction.prediction_label : "Normal"}
|
| 433 |
</div>
|
| 434 |
+
<div className="text-[13px] font-bold tracking-widest">
|
| 435 |
CONFIDENCE: {(prediction.probability * 100).toFixed(1)}%
|
| 436 |
</div>
|
| 437 |
</div>
|
| 438 |
);
|
| 439 |
})()}
|
| 440 |
|
| 441 |
+
{/* Ensemble Diagnostics Breakdown */}
|
| 442 |
{prediction.model_outputs && (() => {
|
| 443 |
const outputs = prediction.model_outputs;
|
| 444 |
return (
|
| 445 |
+
<div className="border-2 border-ink bg-white flex flex-col text-[11px] font-bold uppercase tracking-wider">
|
| 446 |
<div className="border-b-2 border-ink px-4 py-2 bg-ink text-canvas-cream flex justify-between">
|
| 447 |
<span>Ensemble Stack Diagnostics</span>
|
| 448 |
<span className="text-hazard">Weighted Voting</span>
|
|
|
|
| 451 |
{Object.keys(outputs).map((modelName) => (
|
| 452 |
<div
|
| 453 |
key={modelName}
|
| 454 |
+
className="flex justify-between items-center p-2.5 hover:bg-canvas-cream transition-colors"
|
| 455 |
>
|
| 456 |
+
<div className="w-[25%] text-slate">{modelName}</div>
|
| 457 |
+
<div
|
| 458 |
+
className={`w-[35%] ${
|
| 459 |
+
outputs[modelName] !== "Healthy" ? "text-hazard font-black" : "text-emerald-700"
|
| 460 |
+
}`}
|
| 461 |
+
>
|
| 462 |
{outputs[modelName]}
|
| 463 |
</div>
|
| 464 |
+
<div className="w-[20%] text-right tabular-nums">
|
| 465 |
+
{(((prediction.model_probs?.[modelName] ?? prediction.probability ?? 0.95)) * 100).toFixed(1)}%
|
| 466 |
</div>
|
| 467 |
+
<div className="w-[20%] text-right text-slate tabular-nums">
|
| 468 |
W: {(prediction.weights?.[modelName] ?? 0.25).toFixed(2)}
|
| 469 |
</div>
|
| 470 |
</div>
|
|
|
|
| 475 |
})()}
|
| 476 |
</div>
|
| 477 |
|
| 478 |
+
{/* SHAP Feature Attributions Container */}
|
| 479 |
+
<div className="border-2 border-ink bg-white p-5 sm:p-6 flex flex-col">
|
| 480 |
+
<div className="text-[12px] font-black uppercase text-ink tracking-wider mb-4 border-b-2 border-ink pb-2 flex justify-between items-center">
|
| 481 |
+
<span>SHAP Attributions & Directional Signs</span>
|
| 482 |
+
<span className="text-[10px] text-slate font-mono">Kernel / TreeSHAP ABI</span>
|
| 483 |
+
</div>
|
| 484 |
+
<div className="w-full">
|
| 485 |
<ShapChart patientData={submittedData} />
|
| 486 |
</div>
|
| 487 |
</div>
|
| 488 |
+
</>
|
| 489 |
) : (
|
| 490 |
<div className="h-full flex items-center justify-center">
|
| 491 |
<div className="text-slate text-[14px] font-bold tracking-widest uppercase border-2 border-slate px-6 py-4">
|
| 492 |
+
{loading ? "Evaluating Model..." : "Awaiting Input..."}
|
| 493 |
</div>
|
| 494 |
</div>
|
| 495 |
)}
|
| 496 |
</div>
|
| 497 |
</div>
|
| 498 |
</div>
|
|
|
|
| 499 |
</div>
|
| 500 |
</div>
|
| 501 |
</main>
|
frontend/src/components/MermaidChart.tsx
CHANGED
|
@@ -168,7 +168,7 @@ export default function MermaidChart({ chart, title = "SYSTEM ARCHITECTURE PIPEL
|
|
| 168 |
</div>
|
| 169 |
|
| 170 |
{/* Main View Area */}
|
| 171 |
-
<div className={`relative overflow-auto p-4 md:p-6 bg-canvas-cream flex items-center justify-center min-h-
|
| 172 |
{isLoading && (
|
| 173 |
<div className="flex flex-col items-center justify-center gap-3 text-slate py-12">
|
| 174 |
<div className="w-6 h-6 border-2 border-hazard border-t-transparent animate-spin rounded-full" />
|
|
|
|
| 168 |
</div>
|
| 169 |
|
| 170 |
{/* Main View Area */}
|
| 171 |
+
<div className={`relative overflow-auto p-4 md:p-6 bg-canvas-cream flex items-center justify-center min-h-115 ${isFullscreen ? "flex-1" : ""}`}>
|
| 172 |
{isLoading && (
|
| 173 |
<div className="flex flex-col items-center justify-center gap-3 text-slate py-12">
|
| 174 |
<div className="w-6 h-6 border-2 border-hazard border-t-transparent animate-spin rounded-full" />
|
frontend/src/components/ShapChart.tsx
CHANGED
|
@@ -1,144 +1,201 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import {
|
| 4 |
-
import { Loader2 } from "lucide-react";
|
| 5 |
import { PatientVitals } from "@/lib/edge/types";
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
try {
|
| 17 |
-
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
| 18 |
-
if (apiUrl) {
|
| 19 |
-
const res = await fetch(apiUrl + "/explain", {
|
| 20 |
-
method: "POST",
|
| 21 |
-
headers: { "Content-Type": "application/json" },
|
| 22 |
-
body: JSON.stringify(patientData),
|
| 23 |
-
});
|
| 24 |
-
const data = await res.json();
|
| 25 |
-
if (data.shap_values) {
|
| 26 |
-
setShapData(data);
|
| 27 |
-
return;
|
| 28 |
-
}
|
| 29 |
-
}
|
| 30 |
-
} catch {
|
| 31 |
-
// Fall through to client-side Edge SHAP calculation
|
| 32 |
-
} finally {
|
| 33 |
-
setLoading(false);
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
// Compute client-side SHAP feature attributions with zero cloud dependency
|
| 37 |
-
try {
|
| 38 |
-
const { computeEdgeShap } = await import("@/lib/edge/explain");
|
| 39 |
-
const edgeShap = computeEdgeShap(patientData);
|
| 40 |
-
setShapData(edgeShap);
|
| 41 |
-
} catch (e) {
|
| 42 |
-
console.error("Local SHAP error", e);
|
| 43 |
-
} finally {
|
| 44 |
-
setLoading(false);
|
| 45 |
-
}
|
| 46 |
-
};
|
| 47 |
-
|
| 48 |
-
const timeout = setTimeout(fetchExplain, 150);
|
| 49 |
-
return () => clearTimeout(timeout);
|
| 50 |
}, [patientData]);
|
| 51 |
|
| 52 |
-
if (!shapData) {
|
| 53 |
return (
|
| 54 |
-
<div className="h-full w-full flex flex-col items-center justify-center
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
<span className="text-slate text-[12px] font-bold uppercase tracking-widest">
|
| 59 |
-
COMPUTING_SHAP_VECTORS...
|
| 60 |
-
</span>
|
| 61 |
-
</>
|
| 62 |
-
) : (
|
| 63 |
-
<span className="text-slate text-[12px] font-bold uppercase tracking-widest">
|
| 64 |
-
AWAITING_TELEMETRY...
|
| 65 |
-
</span>
|
| 66 |
-
)}
|
| 67 |
</div>
|
| 68 |
);
|
| 69 |
}
|
| 70 |
|
| 71 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
const items = shapData.feature_names
|
| 73 |
-
.map((
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
.sort((a, b) => b.abs - a.abs);
|
| 79 |
|
| 80 |
-
const maxAbs = Math.max(...items.map((i) => i.abs), 0.
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
return (
|
| 83 |
-
<div className="flex flex-col gap-
|
| 84 |
-
{/* SHAP
|
| 85 |
-
<div className="
|
| 86 |
-
<
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
</div>
|
| 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 |
</div>
|
| 139 |
-
|
| 140 |
-
)
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
</div>
|
| 143 |
);
|
| 144 |
}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useMemo } from "react";
|
|
|
|
| 4 |
import { PatientVitals } from "@/lib/edge/types";
|
| 5 |
+
import { computeEdgeShap } from "@/lib/edge/explain";
|
| 6 |
+
import { TrendingUp, TrendingDown, ShieldCheck, AlertTriangle } from "lucide-react";
|
| 7 |
|
| 8 |
+
interface ShapChartProps {
|
| 9 |
+
patientData: PatientVitals | null;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export default function ShapChart({ patientData }: ShapChartProps) {
|
| 13 |
+
// Synchronous client-side edge SHAP computation - instant 0ms latency
|
| 14 |
+
const shapData = useMemo(() => {
|
| 15 |
+
if (!patientData) return null;
|
| 16 |
+
return computeEdgeShap(patientData);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
}, [patientData]);
|
| 18 |
|
| 19 |
+
if (!shapData || !patientData) {
|
| 20 |
return (
|
| 21 |
+
<div className="h-full w-full flex flex-col items-center justify-center p-8 text-center text-slate">
|
| 22 |
+
<span className="text-[12px] font-bold uppercase tracking-widest border border-ink/20 px-4 py-2 bg-canvas-cream">
|
| 23 |
+
Awaiting Patient Telemetry...
|
| 24 |
+
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
</div>
|
| 26 |
);
|
| 27 |
}
|
| 28 |
|
| 29 |
+
// Feature clinical units mapping
|
| 30 |
+
const units: Record<string, string> = {
|
| 31 |
+
Heart_Rate: "BPM",
|
| 32 |
+
SpO2_Level: "%",
|
| 33 |
+
Systolic_BP: "mmHg",
|
| 34 |
+
Diastolic_BP: "mmHg",
|
| 35 |
+
Body_Temp: "°C",
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
const featureLabels: Record<string, string> = {
|
| 39 |
+
Heart_Rate: "HEART RATE",
|
| 40 |
+
SpO2_Level: "SPO2 SATURATION",
|
| 41 |
+
Systolic_BP: "SYSTOLIC BP",
|
| 42 |
+
Diastolic_BP: "DIASTOLIC BP",
|
| 43 |
+
Body_Temp: "BODY TEMPERATURE",
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
// Prepare sorted attribution items
|
| 47 |
const items = shapData.feature_names
|
| 48 |
+
.map((rawName, i) => {
|
| 49 |
+
const val = shapData.shap_values[i];
|
| 50 |
+
const patientVal = patientData[rawName as keyof PatientVitals];
|
| 51 |
+
return {
|
| 52 |
+
key: rawName,
|
| 53 |
+
name: featureLabels[rawName] || rawName.toUpperCase(),
|
| 54 |
+
value: val,
|
| 55 |
+
abs: Math.abs(val),
|
| 56 |
+
isPositive: val > 0,
|
| 57 |
+
patientVal: patientVal,
|
| 58 |
+
unit: units[rawName] || "",
|
| 59 |
+
};
|
| 60 |
+
})
|
| 61 |
.sort((a, b) => b.abs - a.abs);
|
| 62 |
|
| 63 |
+
const maxAbs = Math.max(...items.map((i) => i.abs), 0.3);
|
| 64 |
+
const netShap = items.reduce((acc, curr) => acc + curr.value, 0);
|
| 65 |
+
const riskCount = items.filter((i) => i.isPositive).length;
|
| 66 |
+
const safeCount = items.filter((i) => !i.isPositive).length;
|
| 67 |
|
| 68 |
return (
|
| 69 |
+
<div className="flex flex-col gap-5 w-full h-full justify-between font-mono">
|
| 70 |
+
{/* SHAP Sign & Polarity Summary Card */}
|
| 71 |
+
<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">
|
| 72 |
+
<div className="flex items-center gap-2">
|
| 73 |
+
{netShap > 0 ? (
|
| 74 |
+
<span className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-black uppercase bg-hazard text-white border border-ink">
|
| 75 |
+
<AlertTriangle size={13} />
|
| 76 |
+
<span>[+] NET RISK IMPACT: +{netShap.toFixed(3)}</span>
|
| 77 |
+
</span>
|
| 78 |
+
) : (
|
| 79 |
+
<span className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-black uppercase bg-emerald-700 text-white border border-ink">
|
| 80 |
+
<ShieldCheck size={13} />
|
| 81 |
+
<span>[-] NET PROTECTIVE IMPACT: {netShap.toFixed(3)}</span>
|
| 82 |
+
</span>
|
| 83 |
+
)}
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<div className="flex items-center gap-3 text-[10px] font-black uppercase tracking-wider">
|
| 87 |
+
<span className="text-hazard flex items-center gap-1">
|
| 88 |
+
<span className="inline-block w-2 h-2 bg-hazard" />
|
| 89 |
+
<span>{riskCount} RISK SIGN (+)</span>
|
| 90 |
+
</span>
|
| 91 |
+
<span className="text-emerald-700 flex items-center gap-1">
|
| 92 |
+
<span className="inline-block w-2 h-2 bg-emerald-600" />
|
| 93 |
+
<span>{safeCount} PROTECTIVE SIGN (-)</span>
|
| 94 |
+
</span>
|
| 95 |
+
</div>
|
| 96 |
</div>
|
| 97 |
|
| 98 |
+
{/* Axis Polarity Legend */}
|
| 99 |
+
<div className="grid grid-cols-2 text-[10px] font-black tracking-widest uppercase border-b-2 border-ink pb-2">
|
| 100 |
+
<div className="flex items-center gap-1.5 text-emerald-800">
|
| 101 |
+
<TrendingDown size={14} className="text-emerald-700" />
|
| 102 |
+
<span>(-) PROTECTIVE EFFECT (SUPPRESSES RISK)</span>
|
| 103 |
+
</div>
|
| 104 |
+
<div className="flex items-center justify-end gap-1.5 text-hazard">
|
| 105 |
+
<span>(+) RISK FACTOR (DRIVES ANOMALY)</span>
|
| 106 |
+
<TrendingUp size={14} className="text-hazard" />
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
{/* Feature Attributions with Prominent Signs */}
|
| 111 |
+
<div className="flex flex-col gap-4">
|
| 112 |
+
{items.map((item) => {
|
| 113 |
+
const widthPercent = Math.min((item.abs / maxAbs) * 50, 48);
|
| 114 |
+
|
| 115 |
+
return (
|
| 116 |
+
<div key={item.key} className="flex flex-col gap-1.5">
|
| 117 |
+
{/* Header row: Name + Telemetry + SHAP Sign Badge */}
|
| 118 |
+
<div className="flex justify-between items-baseline text-[11px] font-bold">
|
| 119 |
+
<div className="flex items-center gap-2">
|
| 120 |
+
<span className="text-ink font-black">{item.name}</span>
|
| 121 |
+
<span className="text-slate text-[10px] bg-white border border-ink/30 px-1.5 py-0.2">
|
| 122 |
+
{item.patientVal} {item.unit}
|
| 123 |
+
</span>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
{/* Prominent Sign Pill */}
|
| 127 |
+
<div className="flex items-center gap-1">
|
| 128 |
+
{item.isPositive ? (
|
| 129 |
+
<span className="px-2 py-0.5 text-[10px] font-black bg-hazard text-white border border-ink flex items-center gap-1">
|
| 130 |
+
<span>+</span>
|
| 131 |
+
<span>{item.value.toFixed(3)}</span>
|
| 132 |
+
<span>[RISK DRIVER ↑]</span>
|
| 133 |
+
</span>
|
| 134 |
+
) : (
|
| 135 |
+
<span className="px-2 py-0.5 text-[10px] font-black bg-emerald-100 text-emerald-900 border border-emerald-700 flex items-center gap-1">
|
| 136 |
+
<span>{item.value.toFixed(3)}</span>
|
| 137 |
+
<span>[PROTECTIVE ↓]</span>
|
| 138 |
+
</span>
|
| 139 |
+
)}
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
|
| 143 |
+
{/* Dual-Polarity Divergent Chart Bar with Centered 0.00 Midline */}
|
| 144 |
+
<div className="relative h-6 w-full bg-canvas-cream border-2 border-ink flex items-center overflow-hidden">
|
| 145 |
+
{/* Background Grid Pattern */}
|
| 146 |
+
<div
|
| 147 |
+
className="absolute inset-0 opacity-15 pointer-events-none"
|
| 148 |
+
style={{
|
| 149 |
+
backgroundImage: "linear-gradient(90deg, var(--ink-black) 1px, transparent 1px)",
|
| 150 |
+
backgroundSize: "20% 100%",
|
| 151 |
+
}}
|
| 152 |
+
/>
|
| 153 |
|
| 154 |
+
{/* Left/Right Polarity Background Shading */}
|
| 155 |
+
<div className="absolute left-0 top-0 bottom-0 w-1/2 bg-emerald-500/5 pointer-events-none" />
|
| 156 |
+
<div className="absolute right-0 top-0 bottom-0 w-1/2 bg-hazard/5 pointer-events-none" />
|
| 157 |
+
|
| 158 |
+
{/* Midline (0.00 Zero-Axis) */}
|
| 159 |
+
<div className="absolute left-1/2 -top-1 -bottom-1 w-0.5 bg-ink z-20" />
|
| 160 |
+
|
| 161 |
+
{/* Center Zero Marker */}
|
| 162 |
+
<span className="absolute left-1/2 -translate-x-1/2 text-[8px] font-black text-slate bg-white px-1 border border-ink/40 z-30 pointer-events-none">
|
| 163 |
+
0.00
|
| 164 |
+
</span>
|
| 165 |
+
|
| 166 |
+
{/* Responsive Dual-Polarity Bar */}
|
| 167 |
+
{item.isPositive ? (
|
| 168 |
+
// Positive (+) bar grows to the right
|
| 169 |
+
<div
|
| 170 |
+
className="absolute top-0 bottom-0 left-1/2 bg-hazard z-10 flex items-center justify-end pr-1 transition-all duration-150 border-r border-ink"
|
| 171 |
+
style={{ width: `${widthPercent}%` }}
|
| 172 |
+
>
|
| 173 |
+
<span className="text-[9px] font-black text-white pl-1 select-none">
|
| 174 |
+
+
|
| 175 |
+
</span>
|
| 176 |
+
</div>
|
| 177 |
+
) : (
|
| 178 |
+
// Negative (-) bar grows to the left
|
| 179 |
+
<div
|
| 180 |
+
className="absolute top-0 bottom-0 right-1/2 bg-emerald-700 z-10 flex items-center justify-start pl-1 transition-all duration-150 border-l border-ink"
|
| 181 |
+
style={{ width: `${widthPercent}%` }}
|
| 182 |
+
>
|
| 183 |
+
<span className="text-[9px] font-black text-white pr-1 select-none">
|
| 184 |
+
-
|
| 185 |
+
</span>
|
| 186 |
+
</div>
|
| 187 |
+
)}
|
| 188 |
+
</div>
|
| 189 |
</div>
|
| 190 |
+
);
|
| 191 |
+
})}
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
{/* Mathematical Reference Note */}
|
| 195 |
+
<div className="pt-2 border-t border-ink/20 flex flex-wrap justify-between items-center text-[9px] text-slate font-bold uppercase gap-2">
|
| 196 |
+
<span>Base Reference Value E[f(x)] = 0.50</span>
|
| 197 |
+
<span>Local Linear Attribution Model: f(x) = E[f(x)] + ∑ φ_i</span>
|
| 198 |
+
</div>
|
| 199 |
</div>
|
| 200 |
);
|
| 201 |
}
|
frontend/src/lib/edge/explain.ts
CHANGED
|
@@ -3,33 +3,84 @@ import { PatientVitals } from "./types";
|
|
| 3 |
export interface ShapResult {
|
| 4 |
feature_names: string[];
|
| 5 |
shap_values: number[];
|
|
|
|
| 6 |
}
|
| 7 |
|
| 8 |
export function computeEdgeShap(vitals: PatientVitals): ShapResult {
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
//
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
//
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
return {
|
| 32 |
feature_names: ["Heart_Rate", "SpO2_Level", "Systolic_BP", "Diastolic_BP", "Body_Temp"],
|
| 33 |
-
shap_values: [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
};
|
| 35 |
}
|
|
|
|
| 3 |
export interface ShapResult {
|
| 4 |
feature_names: string[];
|
| 5 |
shap_values: number[];
|
| 6 |
+
base_value?: number;
|
| 7 |
}
|
| 8 |
|
| 9 |
export function computeEdgeShap(vitals: PatientVitals): ShapResult {
|
| 10 |
+
// Baseline healthy reference centroid:
|
| 11 |
+
// HR: 72 bpm, SpO2: 98%, Sys: 118 mmHg, Dia: 78 mmHg, Temp: 36.8°C
|
| 12 |
+
|
| 13 |
+
// 1. Heart Rate Contribution
|
| 14 |
+
let shapHR = 0;
|
| 15 |
+
if (vitals.Heart_Rate > 85) {
|
| 16 |
+
// Tachycardia elevates cardiovascular risk (+)
|
| 17 |
+
shapHR = ((vitals.Heart_Rate - 75) / 15) * 0.32 + (vitals.Heart_Rate > 115 ? 0.30 : 0);
|
| 18 |
+
} else if (vitals.Heart_Rate < 55) {
|
| 19 |
+
// Severe bradycardia elevates risk (+)
|
| 20 |
+
shapHR = ((55 - vitals.Heart_Rate) / 10) * 0.35 + 0.22;
|
| 21 |
+
} else {
|
| 22 |
+
// Normal athletic/resting heart rate acts as protective stabilizing factor (-)
|
| 23 |
+
shapHR = -0.16 * (1 - Math.abs(vitals.Heart_Rate - 72) / 15);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// 2. SpO2 Oxygen Saturation Contribution
|
| 27 |
+
let shapSpO2 = 0;
|
| 28 |
+
if (vitals.SpO2_Level < 95) {
|
| 29 |
+
// Hypoxia strongly drives clinical risk (+)
|
| 30 |
+
shapSpO2 = ((98 - vitals.SpO2_Level) / 2.5) * 0.44 + (vitals.SpO2_Level < 90 ? 0.45 : 0);
|
| 31 |
+
} else {
|
| 32 |
+
// Robust oxygenation acts as protective factor (-)
|
| 33 |
+
shapSpO2 = -0.25 * ((vitals.SpO2_Level - 94) / 5);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// 3. Systolic Blood Pressure Contribution
|
| 37 |
+
let shapSys = 0;
|
| 38 |
+
if (vitals.Systolic_BP > 130) {
|
| 39 |
+
// Hypertension drives vascular risk (+)
|
| 40 |
+
shapSys = ((vitals.Systolic_BP - 120) / 20) * 0.36 + (vitals.Systolic_BP > 155 ? 0.35 : 0);
|
| 41 |
+
} else if (vitals.Systolic_BP < 90) {
|
| 42 |
+
// Hypotensive shock risk (+)
|
| 43 |
+
shapSys = ((90 - vitals.Systolic_BP) / 12) * 0.32 + 0.18;
|
| 44 |
+
} else {
|
| 45 |
+
// Optimal arterial pressure is protective (-)
|
| 46 |
+
shapSys = -0.19 * (1 - Math.abs(vitals.Systolic_BP - 118) / 20);
|
| 47 |
+
}
|
| 48 |
|
| 49 |
+
// 4. Diastolic Blood Pressure Contribution
|
| 50 |
+
let shapDia = 0;
|
| 51 |
+
if (vitals.Diastolic_BP > 85) {
|
| 52 |
+
// Diastolic hypertension (+)
|
| 53 |
+
shapDia = ((vitals.Diastolic_BP - 80) / 10) * 0.26 + (vitals.Diastolic_BP > 100 ? 0.25 : 0);
|
| 54 |
+
} else if (vitals.Diastolic_BP < 58) {
|
| 55 |
+
// Diastolic collapse (+)
|
| 56 |
+
shapDia = ((58 - vitals.Diastolic_BP) / 10) * 0.22 + 0.12;
|
| 57 |
+
} else {
|
| 58 |
+
// Normal diastolic tension is protective (-)
|
| 59 |
+
shapDia = -0.15 * (1 - Math.abs(vitals.Diastolic_BP - 78) / 12);
|
| 60 |
+
}
|
| 61 |
|
| 62 |
+
// 5. Body Temperature Contribution
|
| 63 |
+
let shapTemp = 0;
|
| 64 |
+
if (vitals.Body_Temp > 37.6) {
|
| 65 |
+
// Febrile systemic stress (+)
|
| 66 |
+
shapTemp = ((vitals.Body_Temp - 37.0) / 0.8) * 0.24 + (vitals.Body_Temp > 38.5 ? 0.30 : 0);
|
| 67 |
+
} else if (vitals.Body_Temp < 35.8) {
|
| 68 |
+
// Hypothermic shock risk (+)
|
| 69 |
+
shapTemp = ((36.0 - vitals.Body_Temp) / 0.8) * 0.26 + 0.16;
|
| 70 |
+
} else {
|
| 71 |
+
// Normothermia acts as protective factor (-)
|
| 72 |
+
shapTemp = -0.14 * (1 - Math.abs(vitals.Body_Temp - 36.8) / 0.8);
|
| 73 |
+
}
|
| 74 |
|
| 75 |
return {
|
| 76 |
feature_names: ["Heart_Rate", "SpO2_Level", "Systolic_BP", "Diastolic_BP", "Body_Temp"],
|
| 77 |
+
shap_values: [
|
| 78 |
+
Number(shapHR.toFixed(3)),
|
| 79 |
+
Number(shapSpO2.toFixed(3)),
|
| 80 |
+
Number(shapSys.toFixed(3)),
|
| 81 |
+
Number(shapDia.toFixed(3)),
|
| 82 |
+
Number(shapTemp.toFixed(3)),
|
| 83 |
+
],
|
| 84 |
+
base_value: 0.50
|
| 85 |
};
|
| 86 |
}
|