Pathora / frontend /src /components /ResultsPanel.tsx
nusaibah0110's picture
Updated Results
46b55cb
raw
history blame
15.5 kB
import { useState } from "react";
/* Minimal inline SVG icon components to avoid requiring 'lucide-react' */
const DownloadIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d="M12 3v12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M8 11l4 4 4-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M21 21H3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
const FileTextIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M14 2v6h6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M16 13H8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M16 17H8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
const Loader2Icon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" strokeOpacity="0.25"/>
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
</svg>
);
import { ImageWithFallback } from "./ImageWithFallback";
import { ReportModal } from "./ReportModal";
import axios from "axios";
interface ResultsPanelProps {
uploadedImage: string | null;
result?: any;
loading?: boolean;
}
export function ResultsPanel({ uploadedImage, result, loading }: ResultsPanelProps) {
const [showReportModal, setShowReportModal] = useState(false);
// Make loading detection robust: sometimes values arrive as the string "true" from deployed envs
const isLoading = loading === true || String(loading) === "true";
// Helpful debug information when checking issues on deployed spaces (open browser devtools)
// Keep as debug (console.debug) so it doesn't clutter normal logs.
console.debug("ResultsPanel: props", { loading, isLoading, result });
const handleGenerateReport = async (formData: FormData) => {
try {
const baseURL = import.meta.env.MODE === "development"
? "http://127.0.0.1:7860"
: window.location.origin;
const response = await axios.post(`${baseURL}/reports/`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
if (response.data.html_url) {
// Open report in new tab
window.open(`${baseURL}${response.data.html_url}`, "_blank");
}
if (response.data.pdf_url) {
// Open PDF in new tab when available
window.open(`${baseURL}${response.data.pdf_url}`, "_blank");
}
setShowReportModal(false);
} catch (err: any) {
console.error("Failed to generate report:", err);
alert(err.response?.data?.error || "Failed to generate report");
}
};
// Safely destructure result (keep undefined values when result is null) so we can render
// a stable panel while loading and avoid early returns that change layout.
const {
model_used,
detections,
annotated_image_url,
summary,
// prediction (not used here)
confidence,
} = (result || {}) as any;
const handleDownload = () => {
if (annotated_image_url) {
const link = document.createElement("a");
link.href = annotated_image_url;
link.download = "analysis_result.jpg";
// For Firefox it is necessary to add the link to the DOM
document.body.appendChild(link);
link.click();
link.remove();
}
};
// Precompute some helpers for rendering confidences
const isCINModel = /cin/i.test(String(model_used || ""));
// Determine predicted class from summary.prediction if available, otherwise pick the highest confidence
const predictedClassFromConfidence = (conf: any) => {
try {
const entries = Object.entries(conf || {});
if (entries.length === 0) return "";
return entries.reduce((a: any, b: any) => (Number(a[1]) > Number(b[1]) ? a : b))[0];
} catch (e) {
return "";
}
};
// Prefer the class key present in the `confidence` object to ensure we use the exact key/casing
// (this avoids showing all bars when `summary.prediction` has different casing/format).
const predictedClass = predictedClassFromConfidence(confidence || {}) ||
((summary && (summary.prediction || summary.result)) ? String(summary.prediction || summary.result) : "");
return (
<div className="bg-white rounded-lg shadow-sm p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-800">
{model_used || "Analysis Result"}
</h2>
<p className="text-sm text-gray-500">Automated Image Analysis</p>
</div>
{/* header actions intentionally empty; Generate Report button moved below analysis details */}
</div>
{/* If we're loading, show a centered loader inside the panel */}
{isLoading ? (
<div className="bg-white rounded-lg shadow-sm p-6 flex flex-col items-center justify-center">
<Loader2Icon className="w-10 h-10 text-blue-600 animate-spin mb-3" />
<p className="text-teal-700 font-medium">Analyzing image...</p>
</div>
) : (
// Image
<div className="relative mb-6 rounded-lg overflow-hidden border border-gray-200">
<ImageWithFallback
src={annotated_image_url || uploadedImage || "/ui.jpg"}
alt="Analysis Result"
className="w-full h-64 object-cover"
/>
</div>
)}
{/* Summary Section - model-specific rendering (colposcopy, cytology, histopathology) */}
{summary && (() => {
const model = (model_used || "").toString();
const isColpo = /colpo|colposcopy/i.test(model);
const isCyto = /cyto|cytology/i.test(model);
const isHistoLike = /mwt|cin|histopath/i.test(model);
// ------helper values
const abnormalCount = Number(summary.abnormal_cells) || 0;
const pred = (summary.prediction || summary.result || "").toString().toLowerCase();
const isAbnormal = abnormalCount > 0 || /abnormal|positive|high-grade|malignant/.test(pred);
// Colposcopy: show only Abnormal / Normal (based on abnormal_cells count or prediction)
if (isColpo) {
return (
<div className="bg-gray-50 p-4 rounded-lg mb-6">
<h3 className="text-lg font-semibold text-gray-800 mb-2">AI Summary</h3>
<p className="text-gray-700 text-sm">
<strong>Result:</strong> {isAbnormal ? "Abnormal" : "Normal"}
</p>
<div className="mt-3 text-gray-800 text-sm italic border-t pt-2">
{summary.ai_interpretation || "No AI interpretation available."}
</div>
</div>
);
}
// Cytology: keep existing detailed summary (abnormal/normal counts + avg confidence + interpretation)
if (isCyto) {
return (
<div className="bg-gray-50 p-4 rounded-lg mb-6">
<h3 className="text-lg font-semibold text-gray-800 mb-2">AI Summary</h3>
<p className="text-gray-700 text-sm leading-relaxed">
{typeof summary.abnormal_cells !== 'undefined' && (
<><strong>Abnormal Cells:</strong> {summary.abnormal_cells} <br /></>
)}
{typeof summary.normal_cells !== 'undefined' && (
<><strong>Normal Cells:</strong> {summary.normal_cells} <br /></>
)}
{/* average confidence removed */}
</p>
<div className="mt-3 text-gray-800 text-sm italic border-t pt-2">
{summary.ai_interpretation || "No AI interpretation available."}
</div>
</div>
);
}
// Histopathology / CIN / MWT: show average confidence prominently + interpretation
if (isHistoLike) {
return (
<div className="bg-gray-50 p-4 rounded-lg mb-6">
<h3 className="text-lg font-semibold text-gray-800 mb-2">AI Summary</h3>
<div className="mt-3 text-gray-800 text-sm italic border-t pt-2">
{summary.ai_interpretation || "No AI interpretation available."}
</div>
</div>
);
}
// Fallback: render only the fields that exist to avoid empty labels
return (
<div className="bg-gray-50 p-4 rounded-lg mb-6">
<h3 className="text-lg font-semibold text-gray-800 mb-2">AI Summary</h3>
<p className="text-gray-700 text-sm leading-relaxed">
{typeof summary.abnormal_cells !== 'undefined' && (
<><strong>Abnormal Cells:</strong> {summary.abnormal_cells} <br /></>
)}
{typeof summary.normal_cells !== 'undefined' && (
<><strong>Normal Cells:</strong> {summary.normal_cells} <br /></>
)}
</p>
<div className="mt-3 text-gray-800 text-sm italic border-t pt-2">
{summary.ai_interpretation || "No AI interpretation available."}
</div>
</div>
);
})()}
{/* Detection list */}
{detections && detections.length > 0 && (
<div className="mb-6">
<h4 className="font-semibold text-gray-900 mb-3">
Detected Objects
</h4>
<ul className="text-sm text-gray-700 list-disc list-inside space-y-1">
{detections.map((det: any, i: number) => (
<li key={i}>
{det.name || "Object"} – {(det.confidence * 100).toFixed(1)}%
</li>
))}
</ul>
</div>
)}
{/* Probability / MWT visualization */}
{confidence && (
<div className="mb-6">
<h4 className="font-semibold text-gray-900 mb-3">Confidence Levels</h4>
{/* If MWT, CIN, or Histopathology classifier, show a visual bar for average confidence and per-class bars */}
{model_used && /mwt|cin|histopathology/i.test(model_used) ? (
<div>
{/* Average confidence bar */}
{/* Average confidence removed from visualization */}
{/* Per-class bars */}
<div className="space-y-2">
{isCINModel ? (
// For CIN/colposcopy classifiers, show ONLY the predicted grade (no bars for other classes)
(() => {
const cls = String(predictedClass || "");
const val = (confidence && (confidence as any)[cls]) || 0;
const num = Number(val) || 0;
const pct = num * 100;
const isNegative = cls.toLowerCase().includes("negative") ||
cls.toLowerCase().includes("benign") ||
cls.toLowerCase().includes("low-grade") ||
cls.toLowerCase().includes("cin1");
if (!cls) {
return <div className="text-sm text-gray-600">Prediction not available.</div>;
}
return (
<div key={cls}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-700">{cls}</span>
<span className="text-gray-600">{pct.toFixed(2)}%</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-3">
<div
className={`h-3 rounded-full ${isNegative ? "bg-green-500" : "bg-red-500"}`}
style={{ width: `${pct.toFixed(2)}%` }}
/>
</div>
</div>
);
})()
) : (
Object.entries(confidence).map(([cls, val]) => {
const num = Number(val as any) || 0;
const pct = (num * 100);
// Color coding: Positive/Malignant/High-grade = red, Negative/Benign/Low-grade = green
const isNegative = cls.toLowerCase().includes("negative") ||
cls.toLowerCase().includes("benign") ||
cls.toLowerCase().includes("low-grade");
return (
<div key={cls}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-700">{cls}</span>
<span className="text-gray-600">{pct.toFixed(2)}%</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-3">
<div
className={`h-3 rounded-full ${isNegative ? "bg-green-500" : "bg-red-500"}`}
style={{ width: `${pct.toFixed(2)}%` }}
/>
</div>
</div>
);
})
)}
</div>
{/* Mistral comment */}
<div className="mt-4 bg-gray-50 p-3 rounded-lg text-sm italic text-gray-800 border-t">
{summary?.ai_interpretation || "No AI interpretation available."}
</div>
</div>
) : (
// Fallback display for non-MWT models
<pre className="bg-gray-100 rounded-lg p-3 text-sm overflow-x-auto">
{JSON.stringify(confidence, null, 2)}
</pre>
)}
</div>
)}
{/* Report Generation Modal */}
{/* Show only when not loading and we have at least a result (so Generate Report is available even if summary/confidence are missing) */}
{!isLoading && result && (
<div className="flex items-center justify-end mb-6">
{annotated_image_url && (
<button
onClick={handleDownload}
className="flex items-center gap-2 mr-3 bg-gradient-to-r from-teal-700 via-teal-600 to-teal-700 text-white px-4 py-2 rounded-lg hover:opacity-90 transition-all"
>
<DownloadIcon className="w-4 h-4" />
Download Image
</button>
)}
<button
onClick={() => setShowReportModal(true)}
className="flex items-center gap-2 bg-gradient-to-r from-teal-700 via-teal-600 to-teal-700 text-white px-4 py-2 rounded-lg hover:opacity-90 transition-all"
>
<FileTextIcon className="w-4 h-4" />
Generate Report
</button>
</div>
)}
<ReportModal
isOpen={showReportModal}
onClose={() => setShowReportModal(false)}
onSubmit={handleGenerateReport}
analysisId={annotated_image_url || ""}
// Include annotated_image_url in the analysis summary so the backend can embed it
analysisSummaryJson={summary ? JSON.stringify({ ...summary, model_used, confidence, annotated_image_url }) : "{}"}
/>
</div>
);
}