Spaces:
Runtime error
Runtime error
Upload folder using huggingface_hub
Browse files
frontend/src/app/history/page.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import Link from "next/link";
|
| 4 |
-
import { useEffect, useState, useCallback, useMemo } from "react";
|
| 5 |
import {
|
| 6 |
getSessionHistory,
|
| 7 |
downloadSessionCSV,
|
|
@@ -144,6 +144,8 @@ export default function PatientHistory() {
|
|
| 144 |
const [showReportModal, setShowReportModal] = useState(false);
|
| 145 |
const [reportCopied, setReportCopied] = useState(false);
|
| 146 |
|
|
|
|
|
|
|
| 147 |
const refreshSessionHistory = useCallback(() => {
|
| 148 |
setRecords(getSessionHistory());
|
| 149 |
setLoading(false);
|
|
@@ -209,6 +211,9 @@ export default function PatientHistory() {
|
|
| 209 |
sessionStorage.setItem("heartflow_session_log_v2", JSON.stringify(SAMPLE_CLINICAL_RECORDS));
|
| 210 |
}
|
| 211 |
setRecords(SAMPLE_CLINICAL_RECORDS);
|
|
|
|
|
|
|
|
|
|
| 212 |
};
|
| 213 |
|
| 214 |
const handleOpenReport = () => {
|
|
@@ -441,7 +446,7 @@ ${metrics.narrative}
|
|
| 441 |
</div>
|
| 442 |
|
| 443 |
{/* Telemetry Log Table */}
|
| 444 |
-
<div className="crosshair-corner border-2 border-ink bg-ink">
|
| 445 |
<div className="overflow-x-auto bg-canvas-cream">
|
| 446 |
<table className="w-full text-left">
|
| 447 |
<thead className="bg-ink text-white font-bold text-[12px] tracking-widest uppercase">
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import Link from "next/link";
|
| 4 |
+
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
| 5 |
import {
|
| 6 |
getSessionHistory,
|
| 7 |
downloadSessionCSV,
|
|
|
|
| 144 |
const [showReportModal, setShowReportModal] = useState(false);
|
| 145 |
const [reportCopied, setReportCopied] = useState(false);
|
| 146 |
|
| 147 |
+
const tableSectionRef = useRef<HTMLDivElement>(null);
|
| 148 |
+
|
| 149 |
const refreshSessionHistory = useCallback(() => {
|
| 150 |
setRecords(getSessionHistory());
|
| 151 |
setLoading(false);
|
|
|
|
| 211 |
sessionStorage.setItem("heartflow_session_log_v2", JSON.stringify(SAMPLE_CLINICAL_RECORDS));
|
| 212 |
}
|
| 213 |
setRecords(SAMPLE_CLINICAL_RECORDS);
|
| 214 |
+
setTimeout(() => {
|
| 215 |
+
tableSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
| 216 |
+
}, 60);
|
| 217 |
};
|
| 218 |
|
| 219 |
const handleOpenReport = () => {
|
|
|
|
| 446 |
</div>
|
| 447 |
|
| 448 |
{/* Telemetry Log Table */}
|
| 449 |
+
<div ref={tableSectionRef} className="crosshair-corner border-2 border-ink bg-ink scroll-mt-24">
|
| 450 |
<div className="overflow-x-auto bg-canvas-cream">
|
| 451 |
<table className="w-full text-left">
|
| 452 |
<thead className="bg-ink text-white font-bold text-[12px] tracking-widest uppercase">
|
frontend/src/app/mlops/page.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useState } from "react";
|
| 4 |
import {
|
| 5 |
Download,
|
| 6 |
FileSpreadsheet,
|
|
@@ -99,6 +99,21 @@ export default function MLOps() {
|
|
| 99 |
const [status, setStatus] = useState<"idle" | "uploading" | "success" | "error">("idle");
|
| 100 |
const [message, setMessage] = useState("");
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
const parseAndValidateCSV = async (fileToValidate: File) => {
|
| 103 |
try {
|
| 104 |
const text = await fileToValidate.text();
|
|
@@ -201,6 +216,7 @@ export default function MLOps() {
|
|
| 201 |
setFile(newFile);
|
| 202 |
if (newFile) {
|
| 203 |
parseAndValidateCSV(newFile);
|
|
|
|
| 204 |
} else {
|
| 205 |
setValidation(null);
|
| 206 |
setStatus("idle");
|
|
@@ -229,6 +245,9 @@ export default function MLOps() {
|
|
| 229 |
const handleUpload = async () => {
|
| 230 |
if (!file) return;
|
| 231 |
setStatus("uploading");
|
|
|
|
|
|
|
|
|
|
| 232 |
|
| 233 |
const formData = new FormData();
|
| 234 |
formData.append("file", file);
|
|
@@ -245,6 +264,9 @@ export default function MLOps() {
|
|
| 245 |
if (data.status === "success" || !data.error) {
|
| 246 |
setStatus("success");
|
| 247 |
setMessage(data.message || "Dataset merged and ensemble models successfully retrained!");
|
|
|
|
|
|
|
|
|
|
| 248 |
return;
|
| 249 |
} else {
|
| 250 |
throw new Error(data.error || "Retraining failed");
|
|
@@ -263,6 +285,9 @@ export default function MLOps() {
|
|
| 263 |
setStatus("error");
|
| 264 |
setMessage("Validation failed. Please verify that all 5 continuous physiological features are present.");
|
| 265 |
}
|
|
|
|
|
|
|
|
|
|
| 266 |
};
|
| 267 |
|
| 268 |
return (
|
|
@@ -359,7 +384,11 @@ export default function MLOps() {
|
|
| 359 |
</section>
|
| 360 |
|
| 361 |
{/* Upload & Staging Workspace - Unified Monolithic Workbench */}
|
| 362 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
{/* Dropzone Panel */}
|
| 364 |
<div className="lg:col-span-5 p-6 sm:p-8 flex flex-col justify-between">
|
| 365 |
<div>
|
|
@@ -422,7 +451,8 @@ export default function MLOps() {
|
|
| 422 |
|
| 423 |
{status !== "idle" && (
|
| 424 |
<div
|
| 425 |
-
|
|
|
|
| 426 |
status === "success"
|
| 427 |
? "bg-[#d1fae5] border-[#065f46] text-[#065f46]"
|
| 428 |
: status === "error"
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useState, useRef } from "react";
|
| 4 |
import {
|
| 5 |
Download,
|
| 6 |
FileSpreadsheet,
|
|
|
|
| 99 |
const [status, setStatus] = useState<"idle" | "uploading" | "success" | "error">("idle");
|
| 100 |
const [message, setMessage] = useState("");
|
| 101 |
|
| 102 |
+
const stagingWorkbenchRef = useRef<HTMLDivElement>(null);
|
| 103 |
+
const statusBannerRef = useRef<HTMLDivElement>(null);
|
| 104 |
+
|
| 105 |
+
const scrollToWorkbench = () => {
|
| 106 |
+
if (typeof window === "undefined") return;
|
| 107 |
+
requestAnimationFrame(() => {
|
| 108 |
+
setTimeout(() => {
|
| 109 |
+
stagingWorkbenchRef.current?.scrollIntoView({
|
| 110 |
+
behavior: "smooth",
|
| 111 |
+
block: "start",
|
| 112 |
+
});
|
| 113 |
+
}, 50);
|
| 114 |
+
});
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
const parseAndValidateCSV = async (fileToValidate: File) => {
|
| 118 |
try {
|
| 119 |
const text = await fileToValidate.text();
|
|
|
|
| 216 |
setFile(newFile);
|
| 217 |
if (newFile) {
|
| 218 |
parseAndValidateCSV(newFile);
|
| 219 |
+
scrollToWorkbench();
|
| 220 |
} else {
|
| 221 |
setValidation(null);
|
| 222 |
setStatus("idle");
|
|
|
|
| 245 |
const handleUpload = async () => {
|
| 246 |
if (!file) return;
|
| 247 |
setStatus("uploading");
|
| 248 |
+
setTimeout(() => {
|
| 249 |
+
statusBannerRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
| 250 |
+
}, 60);
|
| 251 |
|
| 252 |
const formData = new FormData();
|
| 253 |
formData.append("file", file);
|
|
|
|
| 264 |
if (data.status === "success" || !data.error) {
|
| 265 |
setStatus("success");
|
| 266 |
setMessage(data.message || "Dataset merged and ensemble models successfully retrained!");
|
| 267 |
+
setTimeout(() => {
|
| 268 |
+
statusBannerRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
| 269 |
+
}, 80);
|
| 270 |
return;
|
| 271 |
} else {
|
| 272 |
throw new Error(data.error || "Retraining failed");
|
|
|
|
| 285 |
setStatus("error");
|
| 286 |
setMessage("Validation failed. Please verify that all 5 continuous physiological features are present.");
|
| 287 |
}
|
| 288 |
+
setTimeout(() => {
|
| 289 |
+
statusBannerRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
| 290 |
+
}, 80);
|
| 291 |
};
|
| 292 |
|
| 293 |
return (
|
|
|
|
| 384 |
</section>
|
| 385 |
|
| 386 |
{/* Upload & Staging Workspace - Unified Monolithic Workbench */}
|
| 387 |
+
<div
|
| 388 |
+
ref={stagingWorkbenchRef}
|
| 389 |
+
id="staging-workbench"
|
| 390 |
+
className="scroll-mt-24 border-2 border-ink bg-white grid grid-cols-1 lg:grid-cols-12 divide-y lg:divide-y-0 lg:divide-x-2 divide-ink shadow-[4px_4px_0px_0px_#141413]"
|
| 391 |
+
>
|
| 392 |
{/* Dropzone Panel */}
|
| 393 |
<div className="lg:col-span-5 p-6 sm:p-8 flex flex-col justify-between">
|
| 394 |
<div>
|
|
|
|
| 451 |
|
| 452 |
{status !== "idle" && (
|
| 453 |
<div
|
| 454 |
+
ref={statusBannerRef}
|
| 455 |
+
className={`p-3 border-2 text-[12px] font-bold uppercase tracking-wider scroll-mt-24 ${
|
| 456 |
status === "success"
|
| 457 |
? "bg-[#d1fae5] border-[#065f46] text-[#065f46]"
|
| 458 |
: status === "error"
|
frontend/src/app/simulator/page.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useState
|
| 4 |
-
import { Sliders, Activity, Cpu, Shuffle
|
| 5 |
import ShapChart from "@/components/ShapChart";
|
| 6 |
import { PatientVitals } from "@/lib/edge/types";
|
| 7 |
import { evaluateEdge } from "@/lib/edge/inference";
|
|
|
|
| 8 |
|
| 9 |
interface PredictionResult {
|
| 10 |
prediction: boolean;
|
|
@@ -42,65 +43,59 @@ const DEFAULT_VITALS: PatientVitals = {
|
|
| 42 |
Body_Temp: 37.0,
|
| 43 |
};
|
| 44 |
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
const [formData, setFormData] = useState<PatientVitals>(DEFAULT_VITALS);
|
| 47 |
-
const [submittedData, setSubmittedData] = useState<PatientVitals | null>(
|
| 48 |
-
const [prediction, setPrediction] = useState<PredictionResult | null>(
|
| 49 |
-
const edge = evaluateEdge(DEFAULT_VITALS);
|
| 50 |
-
return {
|
| 51 |
-
prediction: Boolean(edge.is_at_risk),
|
| 52 |
-
prediction_label: edge.label,
|
| 53 |
-
probability: edge.confidence,
|
| 54 |
-
model_outputs: edge.model_outputs,
|
| 55 |
-
model_probs: edge.model_probs,
|
| 56 |
-
weights: edge.weights,
|
| 57 |
-
};
|
| 58 |
-
});
|
| 59 |
const [loading, setLoading] = useState(false);
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
});
|
| 74 |
-
}, []);
|
| 75 |
-
|
| 76 |
-
// Handler to throw randomized values for all features
|
| 77 |
-
const handleThrowRandomValues = useCallback(() => {
|
| 78 |
const randomVitals = generateRandomVitals();
|
| 79 |
setFormData(randomVitals);
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 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;
|
| 102 |
setLoading(true);
|
| 103 |
-
|
| 104 |
|
| 105 |
try {
|
| 106 |
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
|
@@ -113,174 +108,129 @@ export default function Analysis() {
|
|
| 113 |
if (res.ok) {
|
| 114 |
const data = await res.json();
|
| 115 |
setPrediction(data);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
setLoading(false);
|
| 117 |
return;
|
| 118 |
}
|
| 119 |
}
|
| 120 |
} catch {
|
| 121 |
-
// Cloud API unreachable, seamlessly
|
| 122 |
}
|
| 123 |
|
| 124 |
-
//
|
| 125 |
try {
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
} finally {
|
| 130 |
setLoading(false);
|
| 131 |
}
|
| 132 |
};
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 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-
|
| 149 |
-
{/*
|
| 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
|
| 153 |
<Sliders size={14} />
|
| 154 |
-
|
| 155 |
</div>
|
| 156 |
-
<h1 className="text-[clamp(2.
|
| 157 |
MODEL<br />SIMULATOR
|
| 158 |
</h1>
|
| 159 |
</div>
|
| 160 |
-
<div className="
|
| 161 |
-
<div className="text-[
|
| 162 |
-
|
| 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 -
|
| 214 |
-
<div className="lg:col-span-
|
| 215 |
-
<div>
|
| 216 |
-
<
|
| 217 |
-
<
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 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 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 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 &&
|
| 269 |
-
formData.SpO2_Level === preset.data.SpO2_Level &&
|
| 270 |
-
formData.Systolic_BP === preset.data.Systolic_BP &&
|
| 271 |
-
formData.Diastolic_BP === preset.data.Diastolic_BP;
|
| 272 |
-
|
| 273 |
-
return (
|
| 274 |
-
<button
|
| 275 |
-
key={preset.name}
|
| 276 |
-
type="button"
|
| 277 |
-
onClick={() => handleSelectPreset(preset.data)}
|
| 278 |
-
className={`btn-tactile border-2 border-ink p-3.5 text-left transition-colors group flex flex-col justify-between cursor-pointer ${
|
| 279 |
-
isActive
|
| 280 |
-
? "bg-ink text-white font-bold"
|
| 281 |
-
: "bg-white text-ink hover:bg-canvas-cream"
|
| 282 |
-
}`}
|
| 283 |
-
>
|
| 284 |
<div className="flex justify-between items-center text-[12px] font-black uppercase tracking-wider mb-1">
|
| 285 |
<span>{preset.name}</span>
|
| 286 |
<span
|
|
@@ -297,95 +247,101 @@ export default function Analysis() {
|
|
| 297 |
{preset.tag}
|
| 298 |
</span>
|
| 299 |
</div>
|
| 300 |
-
<div className={`text-[
|
| 301 |
{preset.desc}
|
| 302 |
</div>
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
</
|
| 307 |
-
</
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
</div>
|
|
|
|
| 312 |
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
</div>
|
| 357 |
</div>
|
| 358 |
|
| 359 |
-
|
|
|
|
| 360 |
<button
|
| 361 |
type="button"
|
| 362 |
-
onClick={
|
| 363 |
-
className="btn-tactile py-
|
| 364 |
-
title="
|
| 365 |
>
|
| 366 |
<Shuffle size={14} className="text-hazard" />
|
| 367 |
-
<span>
|
| 368 |
</button>
|
| 369 |
|
| 370 |
<button
|
| 371 |
type="button"
|
| 372 |
-
onClick={
|
| 373 |
disabled={loading}
|
| 374 |
-
className="btn-tactile-hazard py-
|
| 375 |
>
|
| 376 |
-
{loading ? "
|
| 377 |
</button>
|
| 378 |
</div>
|
| 379 |
</div>
|
| 380 |
|
| 381 |
-
{/* Results Panel -
|
| 382 |
-
<div className="lg:col-span-
|
| 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 |
-
|
| 387 |
</h2>
|
| 388 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
</div>
|
| 390 |
|
| 391 |
<div className="grow flex flex-col relative bg-white">
|
|
@@ -398,12 +354,12 @@ export default function Analysis() {
|
|
| 398 |
}}
|
| 399 |
/>
|
| 400 |
|
| 401 |
-
<div className="z-10 p-
|
| 402 |
-
{prediction ? (
|
| 403 |
<>
|
| 404 |
<div>
|
| 405 |
<div className="text-[11px] font-bold text-slate tracking-widest mb-3 uppercase">
|
| 406 |
-
|
| 407 |
</div>
|
| 408 |
|
| 409 |
{(() => {
|
|
@@ -422,16 +378,20 @@ export default function Analysis() {
|
|
| 422 |
return "bg-hazard text-white border-hazard";
|
| 423 |
};
|
| 424 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
return (
|
| 426 |
<div
|
| 427 |
-
className={`border-2 p-
|
| 428 |
prediction.prediction ? prediction.prediction_label : "Healthy"
|
| 429 |
)}`}
|
| 430 |
>
|
| 431 |
-
<div className="font-sans text-[clamp(
|
| 432 |
-
{
|
| 433 |
</div>
|
| 434 |
-
<div className="text-[
|
| 435 |
CONFIDENCE: {(prediction.probability * 100).toFixed(1)}%
|
| 436 |
</div>
|
| 437 |
</div>
|
|
@@ -439,47 +399,44 @@ export default function Analysis() {
|
|
| 439 |
})()}
|
| 440 |
|
| 441 |
{/* Ensemble Diagnostics Breakdown */}
|
| 442 |
-
{prediction.model_outputs && (
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
<
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
|
|
|
| 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 |
-
|
| 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>
|
| 471 |
-
)
|
| 472 |
-
|
| 473 |
</div>
|
| 474 |
-
|
| 475 |
-
|
| 476 |
</div>
|
| 477 |
|
| 478 |
{/* SHAP Feature Attributions Container */}
|
| 479 |
-
<div className="border-2 border-ink bg-white p-
|
| 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>
|
| 482 |
-
<span className="text-[10px] text-slate font-mono">
|
| 483 |
</div>
|
| 484 |
<div className="w-full">
|
| 485 |
<ShapChart patientData={submittedData} />
|
|
@@ -487,9 +444,9 @@ export default function Analysis() {
|
|
| 487 |
</div>
|
| 488 |
</>
|
| 489 |
) : (
|
| 490 |
-
<div className="h-full flex items-center justify-center">
|
| 491 |
-
<div className="text-slate text-[
|
| 492 |
-
{loading ? "
|
| 493 |
</div>
|
| 494 |
</div>
|
| 495 |
)}
|
|
@@ -516,14 +473,14 @@ function InputControl({ label, value, min, max, step = 1, unit, onChange }: Inpu
|
|
| 516 |
return (
|
| 517 |
<div className="flex flex-col gap-2 group">
|
| 518 |
<div className="flex justify-between items-baseline">
|
| 519 |
-
<label className="text-[
|
| 520 |
{label}
|
| 521 |
</label>
|
| 522 |
<div className="flex items-baseline gap-1.5">
|
| 523 |
<span className="font-sans text-[26px] sm:text-[30px] font-black leading-none tabular-nums tracking-tighter">
|
| 524 |
{value}
|
| 525 |
</span>
|
| 526 |
-
{unit && <span className="text-[
|
| 527 |
</div>
|
| 528 |
</div>
|
| 529 |
|
|
@@ -537,9 +494,8 @@ function InputControl({ label, value, min, max, step = 1, unit, onChange }: Inpu
|
|
| 537 |
onChange={(e) => onChange(Number(e.target.value))}
|
| 538 |
className="fader-slider"
|
| 539 |
/>
|
| 540 |
-
<div className="flex justify-between items-center text-[
|
| 541 |
<span>{min}</span>
|
| 542 |
-
<span className="text-ink/25 tracking-widest">| · · · · | · · · · |</span>
|
| 543 |
<span>{max}</span>
|
| 544 |
</div>
|
| 545 |
</div>
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { Sliders, Activity, Cpu, Shuffle } from "lucide-react";
|
| 5 |
import ShapChart from "@/components/ShapChart";
|
| 6 |
import { PatientVitals } from "@/lib/edge/types";
|
| 7 |
import { evaluateEdge } from "@/lib/edge/inference";
|
| 8 |
+
import { appendSessionHistory } from "@/lib/edge/history";
|
| 9 |
|
| 10 |
interface PredictionResult {
|
| 11 |
prediction: boolean;
|
|
|
|
| 43 |
Body_Temp: 37.0,
|
| 44 |
};
|
| 45 |
|
| 46 |
+
const PRESET_VECTORS = [
|
| 47 |
+
{
|
| 48 |
+
name: "BASELINE NORMAL",
|
| 49 |
+
tag: "NORMAL",
|
| 50 |
+
desc: "Normotensive resting telemetry",
|
| 51 |
+
data: { Heart_Rate: 72, SpO2_Level: 98, Systolic_BP: 118, Diastolic_BP: 78, Body_Temp: 37.0 },
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
name: "ISCHEMIC RISK",
|
| 55 |
+
tag: "CRITICAL",
|
| 56 |
+
desc: "Tachycardic desaturation event",
|
| 57 |
+
data: { Heart_Rate: 128, SpO2_Level: 91, Systolic_BP: 165, Diastolic_BP: 104, Body_Temp: 37.3 },
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
name: "HYPOXIC EVENT",
|
| 61 |
+
tag: "WARNING",
|
| 62 |
+
desc: "Hypoxemia with bradycardia depression",
|
| 63 |
+
data: { Heart_Rate: 48, SpO2_Level: 82, Systolic_BP: 88, Diastolic_BP: 54, Body_Temp: 35.8 },
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
name: "HYPERTENSIVE",
|
| 67 |
+
tag: "WARNING",
|
| 68 |
+
desc: "Arterial pressure elevation",
|
| 69 |
+
data: { Heart_Rate: 98, SpO2_Level: 96, Systolic_BP: 195, Diastolic_BP: 118, Body_Temp: 37.1 },
|
| 70 |
+
},
|
| 71 |
+
];
|
| 72 |
+
|
| 73 |
+
export default function Simulator() {
|
| 74 |
const [formData, setFormData] = useState<PatientVitals>(DEFAULT_VITALS);
|
| 75 |
+
const [submittedData, setSubmittedData] = useState<PatientVitals | null>(null);
|
| 76 |
+
const [prediction, setPrediction] = useState<PredictionResult | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
const [loading, setLoading] = useState(false);
|
| 78 |
+
|
| 79 |
+
// Manual slider adjustment updates input parameters only
|
| 80 |
+
const handleSliderChange = (field: keyof PatientVitals, val: number) => {
|
| 81 |
+
setFormData((prev) => ({ ...prev, [field]: val }));
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
// Preset selection updates input parameters only
|
| 85 |
+
const handleSelectPreset = (presetData: PatientVitals) => {
|
| 86 |
+
setFormData({ ...presetData });
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
// Randomization updates input parameters only
|
| 90 |
+
const handleRandomize = () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
const randomVitals = generateRandomVitals();
|
| 92 |
setFormData(randomVitals);
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
// Inference execution runs strictly when the user clicks the action button
|
| 96 |
+
const handlePredict = async () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
setLoading(true);
|
| 98 |
+
const targetData = { ...formData };
|
| 99 |
|
| 100 |
try {
|
| 101 |
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
|
|
|
| 108 |
if (res.ok) {
|
| 109 |
const data = await res.json();
|
| 110 |
setPrediction(data);
|
| 111 |
+
setSubmittedData(targetData);
|
| 112 |
+
|
| 113 |
+
// Append execution record to session audit log
|
| 114 |
+
appendSessionHistory({
|
| 115 |
+
timestamp: new Date().toISOString().replace("T", " ").substring(0, 19),
|
| 116 |
+
heart_rate: targetData.Heart_Rate,
|
| 117 |
+
spo2: targetData.SpO2_Level,
|
| 118 |
+
sys_bp: targetData.Systolic_BP,
|
| 119 |
+
dia_bp: targetData.Diastolic_BP,
|
| 120 |
+
body_temp: targetData.Body_Temp,
|
| 121 |
+
prediction_label: data.prediction_label || (data.prediction ? "Anomaly" : "Normal"),
|
| 122 |
+
confidence: data.probability ?? 0.95,
|
| 123 |
+
is_at_risk: Boolean(data.prediction),
|
| 124 |
+
});
|
| 125 |
+
|
| 126 |
setLoading(false);
|
| 127 |
return;
|
| 128 |
}
|
| 129 |
}
|
| 130 |
} catch {
|
| 131 |
+
// Cloud API unreachable, seamlessly fall back to local Edge inference
|
| 132 |
}
|
| 133 |
|
| 134 |
+
// Zero-Cloud Local TinyML Ensemble Inference Execution
|
| 135 |
try {
|
| 136 |
+
const edgeRes = evaluateEdge(targetData);
|
| 137 |
+
setPrediction({
|
| 138 |
+
prediction: edgeRes.is_at_risk === 1,
|
| 139 |
+
prediction_label: edgeRes.label,
|
| 140 |
+
probability: edgeRes.confidence,
|
| 141 |
+
model_outputs: edgeRes.model_outputs,
|
| 142 |
+
model_probs: edgeRes.model_probs,
|
| 143 |
+
weights: edgeRes.weights,
|
| 144 |
+
});
|
| 145 |
+
setSubmittedData(targetData);
|
| 146 |
+
|
| 147 |
+
// Append execution record to session audit log
|
| 148 |
+
appendSessionHistory({
|
| 149 |
+
timestamp: new Date().toISOString().replace("T", " ").substring(0, 19),
|
| 150 |
+
heart_rate: targetData.Heart_Rate,
|
| 151 |
+
spo2: targetData.SpO2_Level,
|
| 152 |
+
sys_bp: targetData.Systolic_BP,
|
| 153 |
+
dia_bp: targetData.Diastolic_BP,
|
| 154 |
+
body_temp: targetData.Body_Temp,
|
| 155 |
+
prediction_label: edgeRes.label,
|
| 156 |
+
confidence: edgeRes.confidence,
|
| 157 |
+
is_at_risk: edgeRes.is_at_risk === 1,
|
| 158 |
+
});
|
| 159 |
+
} catch (err) {
|
| 160 |
+
console.error("Local inference error", err);
|
| 161 |
} finally {
|
| 162 |
setLoading(false);
|
| 163 |
}
|
| 164 |
};
|
| 165 |
|
| 166 |
+
const isStagedDirty =
|
| 167 |
+
submittedData !== null &&
|
| 168 |
+
(formData.Heart_Rate !== submittedData.Heart_Rate ||
|
| 169 |
+
formData.SpO2_Level !== submittedData.SpO2_Level ||
|
| 170 |
+
formData.Systolic_BP !== submittedData.Systolic_BP ||
|
| 171 |
+
formData.Diastolic_BP !== submittedData.Diastolic_BP ||
|
| 172 |
+
formData.Body_Temp !== submittedData.Body_Temp);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
return (
|
| 175 |
<main className="min-h-dvh bg-canvas-cream flex flex-col font-mono selection:bg-hazard selection:text-white pb-16">
|
| 176 |
+
<div className="max-w-350 mx-auto w-full px-4 sm:px-8 py-10 md:py-14 flex-1 flex flex-col">
|
| 177 |
+
{/* Header */}
|
| 178 |
<header className="mb-8 border-b-4 border-ink pb-8 flex flex-col md:flex-row justify-between md:items-end gap-6">
|
| 179 |
<div>
|
| 180 |
+
<div className="text-[12px] text-hazard font-bold tracking-widest mb-4 flex items-center gap-2">
|
| 181 |
<Sliders size={14} />
|
| 182 |
+
<span>EXPLAINABLE AI MODULE</span>
|
| 183 |
</div>
|
| 184 |
+
<h1 className="text-[clamp(2.75rem,5.5vw,5.5rem)] leading-[0.9]">
|
| 185 |
MODEL<br />SIMULATOR
|
| 186 |
</h1>
|
| 187 |
</div>
|
| 188 |
+
<div className="text-left md:text-right max-w-md">
|
| 189 |
+
<div className="text-[13px] font-bold uppercase tracking-wider border-2 border-ink px-4 py-3 bg-ink text-canvas-cream text-left">
|
| 190 |
+
Manual override. Input custom telemetry to test ensemble classifier and view Shapley feature importance vectors.
|
|
|
|
|
|
|
|
|
|
| 191 |
</div>
|
| 192 |
</div>
|
| 193 |
</header>
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
{/* Main Workbench Grid */}
|
| 196 |
<div className="crosshair-corner grid grid-cols-1 lg:grid-cols-12 gap-px bg-ink border-2 border-ink items-stretch">
|
| 197 |
+
{/* Input Panel - 5 Columns */}
|
| 198 |
+
<div className="lg:col-span-5 bg-canvas-cream flex flex-col border-b-2 lg:border-b-0 border-ink">
|
| 199 |
+
<div className="h-14 px-4 sm:px-6 border-b-2 border-ink bg-white flex justify-between items-center">
|
| 200 |
+
<h2 className="text-[14px] font-bold tracking-wider uppercase flex items-center gap-2">
|
| 201 |
+
<Activity size={16} />
|
| 202 |
+
<span>MANUAL INPUT MATRIX</span>
|
| 203 |
+
</h2>
|
| 204 |
+
<span className="text-[10px] text-slate uppercase font-bold">5 Telemetry Inputs</span>
|
| 205 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
|
| 207 |
+
{/* Clinical Profile Presets - Enlarged & Proportional Presence */}
|
| 208 |
+
<div className="p-5 sm:p-6 bg-canvas-cream border-b-2 border-ink flex flex-col gap-3">
|
| 209 |
+
<div className="text-[11px] font-bold tracking-widest text-slate uppercase flex justify-between items-center">
|
| 210 |
+
<span className="text-ink">CLINICAL TEST PROFILES</span>
|
| 211 |
+
<span className="text-[10px] text-slate">SELECT TO LOAD</span>
|
| 212 |
+
</div>
|
| 213 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
| 214 |
+
{PRESET_VECTORS.map((preset) => {
|
| 215 |
+
const isActive =
|
| 216 |
+
formData.Heart_Rate === preset.data.Heart_Rate &&
|
| 217 |
+
formData.SpO2_Level === preset.data.SpO2_Level &&
|
| 218 |
+
formData.Systolic_BP === preset.data.Systolic_BP &&
|
| 219 |
+
formData.Diastolic_BP === preset.data.Diastolic_BP &&
|
| 220 |
+
formData.Body_Temp === preset.data.Body_Temp;
|
| 221 |
+
|
| 222 |
+
return (
|
| 223 |
+
<button
|
| 224 |
+
key={preset.name}
|
| 225 |
+
type="button"
|
| 226 |
+
onClick={() => handleSelectPreset(preset.data)}
|
| 227 |
+
className={`btn-tactile border-2 border-ink p-3.5 sm:p-4 text-left transition-colors cursor-pointer flex flex-col justify-between gap-2 min-h-[96px] ${
|
| 228 |
+
isActive
|
| 229 |
+
? "bg-ink text-white font-bold"
|
| 230 |
+
: "bg-white text-ink hover:bg-canvas-cream"
|
| 231 |
+
}`}
|
| 232 |
+
>
|
| 233 |
+
<div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
<div className="flex justify-between items-center text-[12px] font-black uppercase tracking-wider mb-1">
|
| 235 |
<span>{preset.name}</span>
|
| 236 |
<span
|
|
|
|
| 247 |
{preset.tag}
|
| 248 |
</span>
|
| 249 |
</div>
|
| 250 |
+
<div className={`text-[11px] leading-snug ${isActive ? "text-white/75" : "text-slate"}`}>
|
| 251 |
{preset.desc}
|
| 252 |
</div>
|
| 253 |
+
</div>
|
| 254 |
+
<div className={`text-[11px] font-mono pt-2 border-t border-ink/15 flex justify-between tabular-nums ${isActive ? "text-white/80" : "text-slate"}`}>
|
| 255 |
+
<span>{preset.data.Heart_Rate} BPM · {preset.data.SpO2_Level}%</span>
|
| 256 |
+
<span className="font-bold">{preset.data.Systolic_BP}/{preset.data.Diastolic_BP} mmHg</span>
|
| 257 |
+
</div>
|
| 258 |
+
</button>
|
| 259 |
+
);
|
| 260 |
+
})}
|
| 261 |
</div>
|
| 262 |
+
</div>
|
| 263 |
|
| 264 |
+
{/* Slider Inputs - Dynamically Distributed to Match Right Panel Height */}
|
| 265 |
+
<div className="p-5 sm:p-6 flex-1 flex flex-col justify-between gap-4">
|
| 266 |
+
<InputControl
|
| 267 |
+
label="HEART RATE"
|
| 268 |
+
unit="BPM"
|
| 269 |
+
value={formData.Heart_Rate}
|
| 270 |
+
min={40}
|
| 271 |
+
max={200}
|
| 272 |
+
onChange={(v: number) => handleSliderChange("Heart_Rate", v)}
|
| 273 |
+
/>
|
| 274 |
+
<InputControl
|
| 275 |
+
label="SPO2 LEVEL"
|
| 276 |
+
unit="%"
|
| 277 |
+
value={formData.SpO2_Level}
|
| 278 |
+
min={70}
|
| 279 |
+
max={100}
|
| 280 |
+
onChange={(v: number) => handleSliderChange("SpO2_Level", v)}
|
| 281 |
+
/>
|
| 282 |
+
<InputControl
|
| 283 |
+
label="SYSTOLIC BP"
|
| 284 |
+
unit="mmHg"
|
| 285 |
+
value={formData.Systolic_BP}
|
| 286 |
+
min={80}
|
| 287 |
+
max={220}
|
| 288 |
+
onChange={(v: number) => handleSliderChange("Systolic_BP", v)}
|
| 289 |
+
/>
|
| 290 |
+
<InputControl
|
| 291 |
+
label="DIASTOLIC BP"
|
| 292 |
+
unit="mmHg"
|
| 293 |
+
value={formData.Diastolic_BP}
|
| 294 |
+
min={40}
|
| 295 |
+
max={140}
|
| 296 |
+
onChange={(v: number) => handleSliderChange("Diastolic_BP", v)}
|
| 297 |
+
/>
|
| 298 |
+
<InputControl
|
| 299 |
+
label="BODY TEMP"
|
| 300 |
+
unit="°C"
|
| 301 |
+
value={formData.Body_Temp}
|
| 302 |
+
min={30}
|
| 303 |
+
max={42}
|
| 304 |
+
step={0.1}
|
| 305 |
+
onChange={(v: number) => handleSliderChange("Body_Temp", v)}
|
| 306 |
+
/>
|
|
|
|
| 307 |
</div>
|
| 308 |
|
| 309 |
+
{/* Action Bar - Anchored to bottom with border */}
|
| 310 |
+
<div className="mt-auto border-t-2 border-ink bg-white p-4 sm:p-5 flex gap-3">
|
| 311 |
<button
|
| 312 |
type="button"
|
| 313 |
+
onClick={handleRandomize}
|
| 314 |
+
className="btn-tactile py-3.5 px-5 border-2 border-ink bg-white hover:bg-canvas-cream text-ink font-bold text-[12px] tracking-wider uppercase flex items-center justify-center gap-2 cursor-pointer transition-colors"
|
| 315 |
+
title="Generate random physiological vitals"
|
| 316 |
>
|
| 317 |
<Shuffle size={14} className="text-hazard" />
|
| 318 |
+
<span>RANDOMIZE</span>
|
| 319 |
</button>
|
| 320 |
|
| 321 |
<button
|
| 322 |
type="button"
|
| 323 |
+
onClick={handlePredict}
|
| 324 |
disabled={loading}
|
| 325 |
+
className="btn-tactile-hazard py-3.5 font-bold text-[13px] sm:text-[14px] tracking-[0.2em] uppercase flex items-center justify-center gap-2 flex-1 cursor-pointer disabled:cursor-not-allowed"
|
| 326 |
>
|
| 327 |
+
{loading ? "PROCESSING REQUEST..." : "EXECUTE INFERENCE"}
|
| 328 |
</button>
|
| 329 |
</div>
|
| 330 |
</div>
|
| 331 |
|
| 332 |
+
{/* Results Panel - 7 Columns */}
|
| 333 |
+
<div className="lg:col-span-7 bg-canvas-cream flex flex-col overflow-hidden">
|
| 334 |
<div className="h-14 px-4 sm:px-6 border-b-2 border-ink bg-white flex justify-between items-center">
|
| 335 |
<h2 className="text-[14px] font-bold tracking-wider uppercase flex items-center gap-2">
|
| 336 |
<Cpu size={16} />
|
| 337 |
+
<span>INFERENCE RESULTS & XAI</span>
|
| 338 |
</h2>
|
| 339 |
+
{isStagedDirty && (
|
| 340 |
+
<span className="text-[10px] text-hazard font-bold uppercase tracking-wider flex items-center gap-1.5">
|
| 341 |
+
<span className="w-1.5 h-1.5 bg-hazard animate-pulse" />
|
| 342 |
+
INPUT MODIFIED · RE-RUN REQUIRED
|
| 343 |
+
</span>
|
| 344 |
+
)}
|
| 345 |
</div>
|
| 346 |
|
| 347 |
<div className="grow flex flex-col relative bg-white">
|
|
|
|
| 354 |
}}
|
| 355 |
/>
|
| 356 |
|
| 357 |
+
<div className="z-10 p-5 sm:p-6 flex flex-col h-full gap-6">
|
| 358 |
+
{prediction && submittedData ? (
|
| 359 |
<>
|
| 360 |
<div>
|
| 361 |
<div className="text-[11px] font-bold text-slate tracking-widest mb-3 uppercase">
|
| 362 |
+
MODEL OUTPUT
|
| 363 |
</div>
|
| 364 |
|
| 365 |
{(() => {
|
|
|
|
| 378 |
return "bg-hazard text-white border-hazard";
|
| 379 |
};
|
| 380 |
|
| 381 |
+
const diagnosisText = prediction.prediction
|
| 382 |
+
? prediction.prediction_label.toUpperCase()
|
| 383 |
+
: "NORMAL";
|
| 384 |
+
|
| 385 |
return (
|
| 386 |
<div
|
| 387 |
+
className={`border-2 p-4 sm:p-5 flex flex-col mb-4 ${getStatusStyles(
|
| 388 |
prediction.prediction ? prediction.prediction_label : "Healthy"
|
| 389 |
)}`}
|
| 390 |
>
|
| 391 |
+
<div className="font-sans text-[clamp(26px,2.5vw,48px)] font-black tracking-tighter leading-none mb-2 uppercase wrap-break-words">
|
| 392 |
+
{diagnosisText}
|
| 393 |
</div>
|
| 394 |
+
<div className="text-[12px] font-bold tracking-widest uppercase">
|
| 395 |
CONFIDENCE: {(prediction.probability * 100).toFixed(1)}%
|
| 396 |
</div>
|
| 397 |
</div>
|
|
|
|
| 399 |
})()}
|
| 400 |
|
| 401 |
{/* Ensemble Diagnostics Breakdown */}
|
| 402 |
+
{prediction.model_outputs && (
|
| 403 |
+
<div className="border-2 border-ink bg-white flex flex-col text-[11px] font-bold uppercase tracking-wider">
|
| 404 |
+
<div className="border-b-2 border-ink px-4 py-2 bg-ink text-canvas-cream flex justify-between">
|
| 405 |
+
<span>ENSEMBLE STACK DIAGNOSTICS</span>
|
| 406 |
+
<span className="text-hazard">WEIGHTED VOTING</span>
|
| 407 |
+
</div>
|
| 408 |
+
<div className="divide-y-2 divide-ink">
|
| 409 |
+
{Object.keys(prediction.model_outputs).map((modelName) => {
|
| 410 |
+
const outputLabel = prediction.model_outputs![modelName];
|
| 411 |
+
const isAnomaly = outputLabel !== "Healthy" && outputLabel !== "Normal";
|
| 412 |
+
return (
|
| 413 |
<div
|
| 414 |
key={modelName}
|
| 415 |
className="flex justify-between items-center p-2.5 hover:bg-canvas-cream transition-colors"
|
| 416 |
>
|
| 417 |
<div className="w-[25%] text-slate">{modelName}</div>
|
| 418 |
+
<div className={`w-[35%] ${isAnomaly ? "text-hazard font-black" : "text-emerald-700"}`}>
|
| 419 |
+
{outputLabel}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
</div>
|
| 421 |
<div className="w-[20%] text-right tabular-nums">
|
| 422 |
+
CONF: {(((prediction.model_probs?.[modelName] ?? prediction.probability ?? 0.95)) * 100).toFixed(1)}%
|
| 423 |
</div>
|
| 424 |
<div className="w-[20%] text-right text-slate tabular-nums">
|
| 425 |
W: {(prediction.weights?.[modelName] ?? 0.25).toFixed(2)}
|
| 426 |
</div>
|
| 427 |
</div>
|
| 428 |
+
);
|
| 429 |
+
})}
|
| 430 |
</div>
|
| 431 |
+
</div>
|
| 432 |
+
)}
|
| 433 |
</div>
|
| 434 |
|
| 435 |
{/* SHAP Feature Attributions Container */}
|
| 436 |
+
<div className="border-2 border-ink bg-white p-4 sm:p-5 flex flex-col">
|
| 437 |
<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">
|
| 438 |
+
<span>SHAPLEY ADDITIVE EXPLANATIONS</span>
|
| 439 |
+
<span className="text-[10px] text-slate font-mono">DIRECTIONAL POLARITY</span>
|
| 440 |
</div>
|
| 441 |
<div className="w-full">
|
| 442 |
<ShapChart patientData={submittedData} />
|
|
|
|
| 444 |
</div>
|
| 445 |
</>
|
| 446 |
) : (
|
| 447 |
+
<div className="h-full min-h-[380px] flex items-center justify-center">
|
| 448 |
+
<div className="text-slate text-[13px] font-bold tracking-widest uppercase border-2 border-slate px-6 py-4 animate-pulse text-center max-w-sm">
|
| 449 |
+
{loading ? "CALCULATING SHAP VALUES..." : "Awaiting Input · Click Execute Inference to Generate Attributions"}
|
| 450 |
</div>
|
| 451 |
</div>
|
| 452 |
)}
|
|
|
|
| 473 |
return (
|
| 474 |
<div className="flex flex-col gap-2 group">
|
| 475 |
<div className="flex justify-between items-baseline">
|
| 476 |
+
<label className="text-[13px] sm:text-[14px] font-black text-slate tracking-wider group-hover:text-ink transition-colors uppercase">
|
| 477 |
{label}
|
| 478 |
</label>
|
| 479 |
<div className="flex items-baseline gap-1.5">
|
| 480 |
<span className="font-sans text-[26px] sm:text-[30px] font-black leading-none tabular-nums tracking-tighter">
|
| 481 |
{value}
|
| 482 |
</span>
|
| 483 |
+
{unit && <span className="text-[12px] font-bold text-hazard">{unit}</span>}
|
| 484 |
</div>
|
| 485 |
</div>
|
| 486 |
|
|
|
|
| 494 |
onChange={(e) => onChange(Number(e.target.value))}
|
| 495 |
className="fader-slider"
|
| 496 |
/>
|
| 497 |
+
<div className="flex justify-between items-center text-[10px] sm:text-[11px] font-mono font-bold text-slate tracking-widest mt-1">
|
| 498 |
<span>{min}</span>
|
|
|
|
| 499 |
<span>{max}</span>
|
| 500 |
</div>
|
| 501 |
</div>
|
frontend/src/components/Navigation.tsx
CHANGED
|
@@ -2,10 +2,10 @@
|
|
| 2 |
|
| 3 |
import Link from "next/link";
|
| 4 |
import { usePathname } from "next/navigation";
|
| 5 |
-
import { useState
|
| 6 |
-
import { Activity, Clock } from "lucide-react";
|
| 7 |
|
| 8 |
const NAV_LINKS = [
|
|
|
|
| 9 |
{ href: "/monitor", label: "MONITOR" },
|
| 10 |
{ href: "/simulator", label: "SIMULATOR" },
|
| 11 |
{ href: "/history", label: "HISTORY" },
|
|
@@ -16,22 +16,8 @@ const NAV_LINKS = [
|
|
| 16 |
|
| 17 |
export default function Navigation() {
|
| 18 |
const pathname = usePathname();
|
| 19 |
-
const [clock, setClock] = useState<string>("");
|
| 20 |
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
| 21 |
|
| 22 |
-
useEffect(() => {
|
| 23 |
-
const updateTime = () => {
|
| 24 |
-
const now = new Date();
|
| 25 |
-
const h = String(now.getHours()).padStart(2, "0");
|
| 26 |
-
const m = String(now.getMinutes()).padStart(2, "0");
|
| 27 |
-
const s = String(now.getSeconds()).padStart(2, "0");
|
| 28 |
-
setClock(`${h}:${m}:${s}`);
|
| 29 |
-
};
|
| 30 |
-
updateTime();
|
| 31 |
-
const interval = setInterval(updateTime, 1000);
|
| 32 |
-
return () => clearInterval(interval);
|
| 33 |
-
}, []);
|
| 34 |
-
|
| 35 |
return (
|
| 36 |
<nav className="w-full border-b-2 border-ink bg-canvas-cream sticky top-0 z-50">
|
| 37 |
<div className="max-w-350 mx-auto px-4 sm:px-8 py-3 flex items-center justify-between gap-4">
|
|
@@ -44,17 +30,6 @@ export default function Navigation() {
|
|
| 44 |
<span className="inline-block w-2.5 h-2.5 bg-hazard" />
|
| 45 |
<span>HEARTFLOW_OS</span>
|
| 46 |
</Link>
|
| 47 |
-
|
| 48 |
-
{/* System Telemetry Pill (Desktop) */}
|
| 49 |
-
<div className="hidden xl:flex items-center gap-2 px-2.5 py-1 bg-white border border-ink/40 text-[11px] font-mono font-bold tracking-wider uppercase text-slate">
|
| 50 |
-
<Activity size={12} className="text-hazard" />
|
| 51 |
-
<span>STREAM: ACTIVE</span>
|
| 52 |
-
<span className="text-ink/30">|</span>
|
| 53 |
-
<span className="flex items-center gap-1 text-ink tabular-nums">
|
| 54 |
-
<Clock size={11} className="text-slate" />
|
| 55 |
-
{clock || "READY"}
|
| 56 |
-
</span>
|
| 57 |
-
</div>
|
| 58 |
</div>
|
| 59 |
|
| 60 |
{/* Desktop Navigation Links */}
|
|
@@ -79,11 +54,6 @@ export default function Navigation() {
|
|
| 79 |
|
| 80 |
{/* Mobile Menu Toggle Button */}
|
| 81 |
<div className="flex md:hidden items-center gap-2">
|
| 82 |
-
{clock && (
|
| 83 |
-
<span className="text-[10px] font-mono font-bold tabular-nums text-slate bg-white px-2 py-1 border border-ink/40">
|
| 84 |
-
{clock}
|
| 85 |
-
</span>
|
| 86 |
-
)}
|
| 87 |
<button
|
| 88 |
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
| 89 |
className="px-3 py-1 border-2 border-ink bg-white text-ink text-[11px] font-bold tracking-widest uppercase hover:bg-ink hover:text-white transition-colors"
|
|
|
|
| 2 |
|
| 3 |
import Link from "next/link";
|
| 4 |
import { usePathname } from "next/navigation";
|
| 5 |
+
import { useState } from "react";
|
|
|
|
| 6 |
|
| 7 |
const NAV_LINKS = [
|
| 8 |
+
{ href: "/", label: "OVERVIEW" },
|
| 9 |
{ href: "/monitor", label: "MONITOR" },
|
| 10 |
{ href: "/simulator", label: "SIMULATOR" },
|
| 11 |
{ href: "/history", label: "HISTORY" },
|
|
|
|
| 16 |
|
| 17 |
export default function Navigation() {
|
| 18 |
const pathname = usePathname();
|
|
|
|
| 19 |
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
return (
|
| 22 |
<nav className="w-full border-b-2 border-ink bg-canvas-cream sticky top-0 z-50">
|
| 23 |
<div className="max-w-350 mx-auto px-4 sm:px-8 py-3 flex items-center justify-between gap-4">
|
|
|
|
| 30 |
<span className="inline-block w-2.5 h-2.5 bg-hazard" />
|
| 31 |
<span>HEARTFLOW_OS</span>
|
| 32 |
</Link>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
</div>
|
| 34 |
|
| 35 |
{/* Desktop Navigation Links */}
|
|
|
|
| 54 |
|
| 55 |
{/* Mobile Menu Toggle Button */}
|
| 56 |
<div className="flex md:hidden items-center gap-2">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
<button
|
| 58 |
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
| 59 |
className="px-3 py-1 border-2 border-ink bg-white text-ink text-[11px] font-bold tracking-widest uppercase hover:bg-ink hover:text-white transition-colors"
|
frontend/src/components/ShapChart.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 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";
|
|
@@ -9,24 +9,57 @@ interface ShapChartProps {
|
|
| 9 |
patientData: PatientVitals | null;
|
| 10 |
}
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
export default function ShapChart({ patientData }: ShapChartProps) {
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
| 15 |
if (!patientData) return null;
|
| 16 |
return computeEdgeShap(patientData);
|
| 17 |
}, [patientData]);
|
| 18 |
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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: "%",
|
|
@@ -37,13 +70,12 @@ export default function ShapChart({ patientData }: ShapChartProps) {
|
|
| 37 |
|
| 38 |
const featureLabels: Record<string, string> = {
|
| 39 |
Heart_Rate: "HEART RATE",
|
| 40 |
-
SpO2_Level: "SPO2
|
| 41 |
Systolic_BP: "SYSTOLIC BP",
|
| 42 |
Diastolic_BP: "DIASTOLIC BP",
|
| 43 |
-
Body_Temp: "BODY
|
| 44 |
};
|
| 45 |
|
| 46 |
-
// Prepare sorted attribution items
|
| 47 |
const items = shapData.feature_names
|
| 48 |
.map((rawName, i) => {
|
| 49 |
const val = shapData.shap_values[i];
|
|
@@ -67,54 +99,54 @@ export default function ShapChart({ patientData }: ShapChartProps) {
|
|
| 67 |
|
| 68 |
return (
|
| 69 |
<div className="flex flex-col gap-5 w-full h-full justify-between font-mono">
|
| 70 |
-
{/* SHAP Sign & Polarity Summary
|
| 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-
|
| 75 |
<AlertTriangle size={13} />
|
| 76 |
-
<span>
|
| 77 |
</span>
|
| 78 |
) : (
|
| 79 |
-
<span className="flex items-center gap-1.5 px-2 py-
|
| 80 |
<ShieldCheck size={13} />
|
| 81 |
-
<span>
|
| 82 |
</span>
|
| 83 |
)}
|
| 84 |
</div>
|
| 85 |
|
| 86 |
-
<div className="flex items-center gap-3 text-[10px] font-
|
| 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
|
| 90 |
</span>
|
| 91 |
-
<span className="text-emerald-
|
| 92 |
-
<span className="inline-block w-2 h-2 bg-emerald-
|
| 93 |
-
<span>{safeCount} PROTECTIVE
|
| 94 |
</span>
|
| 95 |
</div>
|
| 96 |
</div>
|
| 97 |
|
| 98 |
{/* Axis Polarity Legend */}
|
| 99 |
-
<div className="grid grid-cols-2 text-[10px] font-
|
| 100 |
<div className="flex items-center gap-1.5 text-emerald-800">
|
| 101 |
<TrendingDown size={14} className="text-emerald-700" />
|
| 102 |
-
<span>
|
| 103 |
</div>
|
| 104 |
<div className="flex items-center justify-end gap-1.5 text-hazard">
|
| 105 |
-
<span>
|
| 106 |
<TrendingUp size={14} className="text-hazard" />
|
| 107 |
</div>
|
| 108 |
</div>
|
| 109 |
|
| 110 |
-
{/* Feature Attributions with
|
| 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 +
|
| 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>
|
|
@@ -123,18 +155,14 @@ export default function ShapChart({ patientData }: ShapChartProps) {
|
|
| 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
|
| 130 |
-
|
| 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
|
| 136 |
-
|
| 137 |
-
<span>[PROTECTIVE ↓]</span>
|
| 138 |
</span>
|
| 139 |
)}
|
| 140 |
</div>
|
|
@@ -142,7 +170,7 @@ export default function ShapChart({ patientData }: ShapChartProps) {
|
|
| 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
|
| 146 |
<div
|
| 147 |
className="absolute inset-0 opacity-15 pointer-events-none"
|
| 148 |
style={{
|
|
@@ -158,44 +186,23 @@ export default function ShapChart({ patientData }: ShapChartProps) {
|
|
| 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
|
| 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
|
| 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 |
}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useEffect, useState, 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";
|
|
|
|
| 9 |
patientData: PatientVitals | null;
|
| 10 |
}
|
| 11 |
|
| 12 |
+
interface ShapData {
|
| 13 |
+
feature_names: string[];
|
| 14 |
+
shap_values: number[];
|
| 15 |
+
base_value?: number;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
export default function ShapChart({ patientData }: ShapChartProps) {
|
| 19 |
+
const [cloudShap, setCloudShap] = useState<ShapData | null>(null);
|
| 20 |
+
|
| 21 |
+
// Compute zero-cloud edge attributions synchronously whenever patientData changes
|
| 22 |
+
const localShap = useMemo(() => {
|
| 23 |
if (!patientData) return null;
|
| 24 |
return computeEdgeShap(patientData);
|
| 25 |
}, [patientData]);
|
| 26 |
|
| 27 |
+
// Attempt backend TreeSHAP API enhancement if configured
|
| 28 |
+
useEffect(() => {
|
| 29 |
+
if (!patientData || !process.env.NEXT_PUBLIC_API_URL) return;
|
| 30 |
+
|
| 31 |
+
let isCancelled = false;
|
| 32 |
+
|
| 33 |
+
fetch(`${process.env.NEXT_PUBLIC_API_URL}/explain`, {
|
| 34 |
+
method: "POST",
|
| 35 |
+
headers: { "Content-Type": "application/json" },
|
| 36 |
+
body: JSON.stringify(patientData),
|
| 37 |
+
})
|
| 38 |
+
.then((res) => (res.ok ? res.json() : null))
|
| 39 |
+
.then((data) => {
|
| 40 |
+
if (data?.shap_values && !isCancelled) {
|
| 41 |
+
setCloudShap(data);
|
| 42 |
+
}
|
| 43 |
+
})
|
| 44 |
+
.catch(() => {});
|
| 45 |
+
|
| 46 |
+
return () => {
|
| 47 |
+
isCancelled = true;
|
| 48 |
+
};
|
| 49 |
+
}, [patientData]);
|
| 50 |
+
|
| 51 |
+
const shapData = cloudShap || localShap;
|
| 52 |
+
|
| 53 |
+
if (!patientData || !shapData) {
|
| 54 |
return (
|
| 55 |
+
<div className="h-full min-h-60 w-full flex flex-col items-center justify-center p-8 text-center text-slate">
|
| 56 |
<span className="text-[12px] font-bold uppercase tracking-widest border border-ink/20 px-4 py-2 bg-canvas-cream">
|
| 57 |
+
Awaiting Input · Run Inference to Generate Attributions
|
| 58 |
</span>
|
| 59 |
</div>
|
| 60 |
);
|
| 61 |
}
|
| 62 |
|
|
|
|
| 63 |
const units: Record<string, string> = {
|
| 64 |
Heart_Rate: "BPM",
|
| 65 |
SpO2_Level: "%",
|
|
|
|
| 70 |
|
| 71 |
const featureLabels: Record<string, string> = {
|
| 72 |
Heart_Rate: "HEART RATE",
|
| 73 |
+
SpO2_Level: "SPO2 LEVEL",
|
| 74 |
Systolic_BP: "SYSTOLIC BP",
|
| 75 |
Diastolic_BP: "DIASTOLIC BP",
|
| 76 |
+
Body_Temp: "BODY TEMP",
|
| 77 |
};
|
| 78 |
|
|
|
|
| 79 |
const items = shapData.feature_names
|
| 80 |
.map((rawName, i) => {
|
| 81 |
const val = shapData.shap_values[i];
|
|
|
|
| 99 |
|
| 100 |
return (
|
| 101 |
<div className="flex flex-col gap-5 w-full h-full justify-between font-mono">
|
| 102 |
+
{/* SHAP Sign & Polarity Summary */}
|
| 103 |
<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">
|
| 104 |
<div className="flex items-center gap-2">
|
| 105 |
{netShap > 0 ? (
|
| 106 |
+
<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">
|
| 107 |
<AlertTriangle size={13} />
|
| 108 |
+
<span>NET RISK ATTRIBUTION: +{netShap.toFixed(3)}</span>
|
| 109 |
</span>
|
| 110 |
) : (
|
| 111 |
+
<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">
|
| 112 |
<ShieldCheck size={13} />
|
| 113 |
+
<span>NET PROTECTIVE ATTRIBUTION: {netShap.toFixed(3)}</span>
|
| 114 |
</span>
|
| 115 |
)}
|
| 116 |
</div>
|
| 117 |
|
| 118 |
+
<div className="flex items-center gap-3 text-[10px] font-bold uppercase tracking-wider">
|
| 119 |
<span className="text-hazard flex items-center gap-1">
|
| 120 |
<span className="inline-block w-2 h-2 bg-hazard" />
|
| 121 |
+
<span>{riskCount} RISK FACTORS</span>
|
| 122 |
</span>
|
| 123 |
+
<span className="text-emerald-800 flex items-center gap-1">
|
| 124 |
+
<span className="inline-block w-2 h-2 bg-emerald-700" />
|
| 125 |
+
<span>{safeCount} PROTECTIVE</span>
|
| 126 |
</span>
|
| 127 |
</div>
|
| 128 |
</div>
|
| 129 |
|
| 130 |
{/* Axis Polarity Legend */}
|
| 131 |
+
<div className="grid grid-cols-2 text-[10px] font-bold tracking-widest uppercase border-b-2 border-ink pb-2">
|
| 132 |
<div className="flex items-center gap-1.5 text-emerald-800">
|
| 133 |
<TrendingDown size={14} className="text-emerald-700" />
|
| 134 |
+
<span>PROTECTIVE (SUPPRESSES RISK)</span>
|
| 135 |
</div>
|
| 136 |
<div className="flex items-center justify-end gap-1.5 text-hazard">
|
| 137 |
+
<span>RISK FACTOR (DRIVES ANOMALY)</span>
|
| 138 |
<TrendingUp size={14} className="text-hazard" />
|
| 139 |
</div>
|
| 140 |
</div>
|
| 141 |
|
| 142 |
+
{/* Feature Attributions with Calibrated Midline Bars */}
|
| 143 |
<div className="flex flex-col gap-4">
|
| 144 |
{items.map((item) => {
|
| 145 |
const widthPercent = Math.min((item.abs / maxAbs) * 50, 48);
|
| 146 |
|
| 147 |
return (
|
| 148 |
<div key={item.key} className="flex flex-col gap-1.5">
|
| 149 |
+
{/* Header row: Feature Name + Patient Vitals + Signed Attribution */}
|
| 150 |
<div className="flex justify-between items-baseline text-[11px] font-bold">
|
| 151 |
<div className="flex items-center gap-2">
|
| 152 |
<span className="text-ink font-black">{item.name}</span>
|
|
|
|
| 155 |
</span>
|
| 156 |
</div>
|
| 157 |
|
|
|
|
| 158 |
<div className="flex items-center gap-1">
|
| 159 |
{item.isPositive ? (
|
| 160 |
+
<span className="px-2 py-0.5 text-[10px] font-black bg-hazard text-white border border-ink">
|
| 161 |
+
+{item.value.toFixed(3)}
|
|
|
|
|
|
|
| 162 |
</span>
|
| 163 |
) : (
|
| 164 |
+
<span className="px-2 py-0.5 text-[10px] font-black bg-emerald-100 text-emerald-900 border border-emerald-700">
|
| 165 |
+
{item.value.toFixed(3)}
|
|
|
|
| 166 |
</span>
|
| 167 |
)}
|
| 168 |
</div>
|
|
|
|
| 170 |
|
| 171 |
{/* Dual-Polarity Divergent Chart Bar with Centered 0.00 Midline */}
|
| 172 |
<div className="relative h-6 w-full bg-canvas-cream border-2 border-ink flex items-center overflow-hidden">
|
| 173 |
+
{/* Background Calibration Grid */}
|
| 174 |
<div
|
| 175 |
className="absolute inset-0 opacity-15 pointer-events-none"
|
| 176 |
style={{
|
|
|
|
| 186 |
{/* Midline (0.00 Zero-Axis) */}
|
| 187 |
<div className="absolute left-1/2 -top-1 -bottom-1 w-0.5 bg-ink z-20" />
|
| 188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
{/* Responsive Dual-Polarity Bar */}
|
| 190 |
{item.isPositive ? (
|
|
|
|
| 191 |
<div
|
| 192 |
+
className="absolute top-0 bottom-0 left-1/2 bg-hazard z-10 h-full transition-all duration-150 border-r border-ink"
|
| 193 |
style={{ width: `${widthPercent}%` }}
|
| 194 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
) : (
|
|
|
|
| 196 |
<div
|
| 197 |
+
className="absolute top-0 bottom-0 right-1/2 bg-emerald-700 z-10 h-full transition-all duration-150 border-l border-ink"
|
| 198 |
style={{ width: `${widthPercent}%` }}
|
| 199 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
)}
|
| 201 |
</div>
|
| 202 |
</div>
|
| 203 |
);
|
| 204 |
})}
|
| 205 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
</div>
|
| 207 |
);
|
| 208 |
}
|