olivas/frontend/src/pages/AnalysisView.tsx
Vadym Samoilenko e9e19fbd9f Load API images via authenticated fetch instead of bare img src
img tags don't send Authorization headers; AuthImage component fetches
via axios (with Bearer token) and renders a blob URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 14:01:33 +00:00

358 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useAnalysis, useDeleteAnalysis } from "../hooks/useAnalysis";
import { useAnalysisStore, type AnalysisTab } from "../stores/analysisStore";
import { getAnalysisImageUrl, checkAIInsightsAvailable, generateAIInsights } from "../api/analysis";
import { AuthImage } from "../components/AuthImage";
import type { Insight } from "../types/analysis";
function getScoreInfo(score: number) {
if (score >= 55) return { label: "High", color: "#16a34a", bg: "#f0fdf4", desc: "Strong design effectiveness — clear visual hierarchy with intentional attention flow" };
if (score >= 35) return { label: "Medium", color: "#d97706", bg: "#fffbeb", desc: "Moderate effectiveness — some competing elements dilute the primary focus" };
return { label: "Low", color: "#dc2626", bg: "#fef2f2", desc: "Low effectiveness — attention is scattered without clear hierarchy" };
}
function ScoreCard({ score, entropyScore }: { score: number; entropyScore?: number }) {
const info = getScoreInfo(score);
const [showTooltip, setShowTooltip] = useState(false);
return (
<div className="flex items-center gap-4">
<div className="relative">
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-lg cursor-help"
style={{ backgroundColor: info.bg, border: `1px solid ${info.color}30` }}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold" style={{ color: info.color }}>{score.toFixed(0)}</span>
<span className="text-xs text-gray-400">/100</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-500">Design Effectiveness</span>
<span className="text-xs font-semibold" style={{ color: info.color }}>{info.label}</span>
</div>
</div>
{showTooltip && (
<div className="absolute top-full left-0 mt-2 z-50 w-80 bg-white rounded-lg shadow-lg border border-gray-200 p-3 text-xs">
<p className="font-semibold text-gray-800 mb-1">Design Effectiveness Score</p>
<p className="text-gray-600 mb-2">{info.desc}</p>
<div className="space-y-1 text-gray-500">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500" />
<span><strong>55-100:</strong> High clear hierarchy, dominant focal point, coherent gaze flow</span>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-amber-500" />
<span><strong>35-54:</strong> Medium some competing elements dilute focus</span>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-red-500" />
<span><strong>0-34:</strong> Low scattered attention, no clear hierarchy</span>
</div>
</div>
<p className="text-gray-400 mt-2 text-[10px]">
Composite of peak dominance, hierarchy clarity, gaze coherence, and entropy concentration.
</p>
{entropyScore !== undefined && (
<div className="mt-2 border-t border-gray-100 pt-2">
<div className="flex items-center justify-between">
<span className="text-gray-500">Entropy Concentration:</span>
<span className="font-semibold text-gray-700">{entropyScore.toFixed(0)}/100</span>
</div>
<p className="text-gray-400 mt-1 text-[10px]">
Raw measure of how spatially concentrated attention is. Lower values mean attention spreads across more of the image.
</p>
</div>
)}
</div>
)}
</div>
</div>
);
}
function ZoomableImage({ src, alt }: { src: string; alt: string }) {
const zoom = useAnalysisStore((s) => s.zoom);
return (
<div className="overflow-auto">
<AuthImage
src={src}
alt={alt}
className="object-contain"
style={{ width: `${zoom * 100}%`, maxWidth: "none" }}
/>
</div>
);
}
function ZoomControls() {
const zoom = useAnalysisStore((s) => s.zoom);
const setZoom = useAnalysisStore((s) => s.setZoom);
return (
<div className="flex items-center gap-2 px-3">
<button
onClick={() => setZoom(Math.max(0.25, zoom - 0.25))}
className="w-7 h-7 flex items-center justify-center text-gray-500 hover:text-gray-700 border border-gray-300 rounded text-sm font-bold"
>
</button>
<span className="text-xs text-gray-500 w-10 text-center">{Math.round(zoom * 100)}%</span>
<button
onClick={() => setZoom(Math.min(1.5, zoom + 0.25))}
className="w-7 h-7 flex items-center justify-center text-gray-500 hover:text-gray-700 border border-gray-300 rounded text-sm font-bold"
>
+
</button>
{zoom !== 1 && (
<button
onClick={() => setZoom(1)}
className="text-xs text-gray-500 hover:text-gray-700 px-2 py-1 border border-gray-300 rounded"
>
Reset
</button>
)}
</div>
);
}
import { downloadReport } from "../api/reports";
import HeatmapOverlay from "../components/analysis/HeatmapOverlay";
import HeatmapControls from "../components/analysis/HeatmapControls";
import GazeSequence from "../components/analysis/GazeSequence";
import HotspotList from "../components/analysis/HotspotList";
import InsightsPanel from "../components/analysis/InsightsPanel";
import AOICanvas from "../components/aoi/AOICanvas";
import AOIResults from "../components/aoi/AOIResults";
import Card from "../components/common/Card";
import Button from "../components/common/Button";
import LoadingSpinner from "../components/common/LoadingSpinner";
const tabs: { key: AnalysisTab; label: string }[] = [
{ key: "heatmap", label: "Heatmap" },
{ key: "gaze", label: "Gaze Sequence" },
{ key: "hotspots", label: "Hotspots" },
{ key: "aoi", label: "AOI Analysis" },
];
export default function AnalysisView() {
const { analysisId } = useParams<{ analysisId: string }>();
const navigate = useNavigate();
const { data: analysis, isLoading, error } = useAnalysis(analysisId);
const deleteAnalysisMutation = useDeleteAnalysis();
const activeTab = useAnalysisStore((s) => s.activeTab);
const setActiveTab = useAnalysisStore((s) => s.setActiveTab);
const setCurrentAnalysis = useAnalysisStore((s) => s.setCurrentAnalysis);
const aoiResults = useAnalysisStore((s) => s.aoiResults);
const reset = useAnalysisStore((s) => s.reset);
const [aiAvailable, setAiAvailable] = useState(false);
const [aiInsights, setAiInsights] = useState<Insight[]>([]);
const [aiScore, setAiScore] = useState<number | null>(null);
const [aiScoreReason, setAiScoreReason] = useState<string | null>(null);
const [aiCostUsd, setAiCostUsd] = useState<number | null>(null);
const [aiLoading, setAiLoading] = useState(false);
const [aiError, setAiError] = useState<string | null>(null);
useEffect(() => {
checkAIInsightsAvailable().then(setAiAvailable);
}, []);
// Load saved AI insights from DB when analysis loads
useEffect(() => {
if (analysis?.ai_insights && analysis.ai_insights.length > 0) {
setAiInsights(analysis.ai_insights);
setAiScore(analysis.ai_score ?? null);
setAiScoreReason(analysis.ai_score_reason ?? null);
setAiCostUsd(analysis.ai_cost_usd ?? null);
}
}, [analysis]);
const handleGenerateAI = async () => {
if (!analysisId) return;
setAiLoading(true);
setAiError(null);
try {
const result = await generateAIInsights(analysisId);
setAiInsights(result.insights);
setAiScore(result.ai_score);
setAiScoreReason(result.score_reason);
setAiCostUsd(result.cost_usd);
} catch (err: any) {
setAiError(err?.response?.data?.detail || "AI analysis failed");
} finally {
setAiLoading(false);
}
};
useEffect(() => {
if (analysis) {
setCurrentAnalysis(analysis);
}
return () => {
reset();
};
}, [analysis, setCurrentAnalysis, reset]);
const handleDeleteAnalysis = async () => {
if (!analysisId) return;
if (!window.confirm("Are you sure you want to delete this analysis? This cannot be undone.")) return;
await deleteAnalysisMutation.mutateAsync(analysisId);
navigate(-1);
};
const handleDownloadPdf = async () => {
if (!analysisId) return;
try {
await downloadReport(analysisId);
} catch (err) {
console.error("Failed to download report:", err);
}
};
if (isLoading) {
return <LoadingSpinner size="lg" message="Loading analysis..." />;
}
if (error || !analysis) {
return (
<div className="text-center py-12">
<p className="text-red-500 mb-4">
Failed to load analysis. It may not exist or is still processing.
</p>
<Button variant="secondary" onClick={() => window.history.back()}>
Go Back
</Button>
</div>
);
}
const originalUrl = getAnalysisImageUrl(analysis.id, "original");
const saliencyUrl = getAnalysisImageUrl(analysis.id, "saliency-raw");
const gazeUrl = getAnalysisImageUrl(analysis.id, "gaze-sequence");
return (
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold" style={{ color: "#1a1a2e" }}>
{analysis.name}
</h1>
<div className="flex items-center gap-4 mt-1 text-sm text-gray-500">
<span>Model: {(analysis.model_used || analysis.model).replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase())}</span>
<span>
{new Date(analysis.created_at).toLocaleDateString()}
</span>
</div>
{analysis.overall_score !== undefined && (
<div className="mt-2">
<ScoreCard score={analysis.overall_score} entropyScore={analysis.entropy_score} />
</div>
)}
</div>
<div className="flex items-center gap-3">
{aiAvailable && analysis.status === "completed" && aiInsights.length === 0 && (
<Button
variant="secondary"
onClick={handleGenerateAI}
loading={aiLoading}
>
Generate AI Analysis
</Button>
)}
<Button onClick={handleDownloadPdf}>Download PDF</Button>
<Button
variant="danger"
onClick={handleDeleteAnalysis}
loading={deleteAnalysisMutation.isPending}
>
Delete
</Button>
</div>
</div>
{/* Tabs + Zoom */}
<div className="border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-5 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.key
? "border-[#ffc407] text-[#ffc407]"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
{tab.label}
</button>
))}
</div>
<ZoomControls />
</div>
</div>
{/* Insights */}
{((analysis.insights && analysis.insights.length > 0) || aiInsights.length > 0) && (
<InsightsPanel insights={analysis.insights || []} aiInsights={aiInsights} aiScore={aiScore} aiScoreReason={aiScoreReason} aiCostUsd={aiCostUsd} />
)}
{aiError && (
<div className="text-sm text-red-500 bg-red-50 border border-red-200 rounded-lg px-4 py-2">
{aiError}
</div>
)}
{/* Tab content */}
{activeTab === "heatmap" && (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-3">
<Card padding={false} className="overflow-auto p-2">
<HeatmapOverlay
originalUrl={originalUrl}
saliencyUrl={saliencyUrl}
/>
</Card>
</div>
<div className="lg:col-span-1">
<HeatmapControls />
</div>
</div>
)}
{activeTab === "gaze" && (
<GazeSequence
imageUrl={gazeUrl}
gazePoints={
(analysis.gaze_sequence || analysis.gaze_points || []).map((p: any) => ({
rank: p.rank,
x: p.x,
y: p.y,
intensity: p.probability ?? p.intensity ?? 0,
}))
}
/>
)}
{activeTab === "hotspots" && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card padding={false} className="overflow-auto">
<ZoomableImage
src={getAnalysisImageUrl(analysis.id, "heatmap")}
alt="Heatmap with hotspots"
/>
</Card>
<HotspotList hotspots={analysis.hotspots || []} />
</div>
)}
{activeTab === "aoi" && (
<div className="space-y-6">
<AOICanvas analysisId={analysis.id} imageUrl={originalUrl} />
<AOIResults results={aoiResults} />
</div>
)}
</div>
);
}