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>
358 lines
14 KiB
TypeScript
358 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|