Major changes: - Dashboard: WebSocket-based chat with session management - Dashboard: New chat components (ChatPane, ChatInput, ModeToggle) - Dashboard: Enhanced UI with parallel coordinates chart - MCP Server: New atomizer-tools server for Claude integration - Extractors: Enhanced Zernike OPD extractor - Reports: Improved report generator New studies (configs and scripts only): - M1 Mirror: Cost reduction campaign studies - Simple Beam, Simple Bracket, UAV Arm studies Note: Large iteration data (2_iterations/, best_design_archive/) excluded via .gitignore - kept on local Gitea only. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
776 lines
30 KiB
TypeScript
776 lines
30 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
Eye,
|
|
RefreshCw,
|
|
Maximize2,
|
|
X,
|
|
Activity,
|
|
Thermometer,
|
|
Waves,
|
|
Grid3X3,
|
|
Box,
|
|
AlertCircle,
|
|
CheckCircle,
|
|
Lightbulb,
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Folder,
|
|
Play,
|
|
ExternalLink,
|
|
Zap,
|
|
List,
|
|
LucideIcon
|
|
} from 'lucide-react';
|
|
import { useStudy } from '../context/StudyContext';
|
|
import { Card } from '../components/common/Card';
|
|
import Plot from 'react-plotly.js';
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
interface IterationInfo {
|
|
id: string;
|
|
path: string;
|
|
op2_file: string;
|
|
modified: number;
|
|
type: 'iteration' | 'best';
|
|
label?: string;
|
|
}
|
|
|
|
interface InsightInfo {
|
|
type: string;
|
|
name: string;
|
|
description: string;
|
|
category?: string;
|
|
category_label?: string;
|
|
applicable_to: string[];
|
|
}
|
|
|
|
interface GeneratedFile {
|
|
filename: string;
|
|
insight_type: string;
|
|
timestamp: string | null;
|
|
size_kb: number;
|
|
modified: number;
|
|
}
|
|
|
|
interface InsightResult {
|
|
success: boolean;
|
|
insight_type: string;
|
|
insight_name?: string;
|
|
linked_objective?: string | null;
|
|
iteration?: string;
|
|
plotly_figure: any;
|
|
summary: Record<string, any>;
|
|
html_path: string | null;
|
|
error?: string;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Constants
|
|
// ============================================================================
|
|
const INSIGHT_ICONS: Record<string, LucideIcon> = {
|
|
zernike_wfe: Waves,
|
|
zernike_opd_comparison: Waves,
|
|
msf_zernike: Waves,
|
|
stress_field: Activity,
|
|
modal: Box,
|
|
thermal: Thermometer,
|
|
design_space: Grid3X3
|
|
};
|
|
|
|
const INSIGHT_COLORS: Record<string, string> = {
|
|
zernike_wfe: 'text-blue-400 bg-blue-500/10 border-blue-500/30',
|
|
zernike_opd_comparison: 'text-blue-400 bg-blue-500/10 border-blue-500/30',
|
|
msf_zernike: 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30',
|
|
stress_field: 'text-red-400 bg-red-500/10 border-red-500/30',
|
|
modal: 'text-purple-400 bg-purple-500/10 border-purple-500/30',
|
|
thermal: 'text-orange-400 bg-orange-500/10 border-orange-500/30',
|
|
design_space: 'text-green-400 bg-green-500/10 border-green-500/30'
|
|
};
|
|
|
|
const CATEGORY_COLORS: Record<string, string> = {
|
|
optical: 'border-blue-500/50 bg-blue-500/5',
|
|
structural_static: 'border-red-500/50 bg-red-500/5',
|
|
structural_dynamic: 'border-orange-500/50 bg-orange-500/5',
|
|
structural_modal: 'border-purple-500/50 bg-purple-500/5',
|
|
thermal: 'border-yellow-500/50 bg-yellow-500/5',
|
|
kinematic: 'border-teal-500/50 bg-teal-500/5',
|
|
design_exploration: 'border-green-500/50 bg-green-500/5',
|
|
other: 'border-gray-500/50 bg-gray-500/5'
|
|
};
|
|
|
|
// ============================================================================
|
|
// Main Component
|
|
// ============================================================================
|
|
export default function Insights() {
|
|
const navigate = useNavigate();
|
|
const { selectedStudy, isInitialized } = useStudy();
|
|
|
|
// State - Step-based workflow
|
|
const [step, setStep] = useState<'select-iteration' | 'select-insight' | 'view-result'>('select-iteration');
|
|
|
|
// Data
|
|
const [iterations, setIterations] = useState<IterationInfo[]>([]);
|
|
const [availableInsights, setAvailableInsights] = useState<InsightInfo[]>([]);
|
|
const [generatedFiles, setGeneratedFiles] = useState<GeneratedFile[]>([]);
|
|
|
|
// Selections
|
|
const [selectedIteration, setSelectedIteration] = useState<IterationInfo | null>(null);
|
|
const [selectedInsightType, setSelectedInsightType] = useState<string | null>(null);
|
|
|
|
// Result
|
|
const [activeInsight, setActiveInsight] = useState<InsightResult | null>(null);
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
|
|
// Loading states
|
|
const [loadingIterations, setLoadingIterations] = useState(true);
|
|
const [loadingInsights, setLoadingInsights] = useState(false);
|
|
const [generating, setGenerating] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Redirect if no study
|
|
useEffect(() => {
|
|
if (isInitialized && !selectedStudy) {
|
|
navigate('/');
|
|
}
|
|
}, [selectedStudy, navigate, isInitialized]);
|
|
|
|
// Load iterations on mount (single fast API call + generated files for quick access)
|
|
const loadIterations = useCallback(async () => {
|
|
if (!selectedStudy) return;
|
|
|
|
setLoadingIterations(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Load iterations and generated files in parallel
|
|
const [iterRes, genRes] = await Promise.all([
|
|
fetch(`/api/insights/studies/${selectedStudy.id}/iterations`),
|
|
fetch(`/api/insights/studies/${selectedStudy.id}/generated`)
|
|
]);
|
|
|
|
const [iterData, genData] = await Promise.all([
|
|
iterRes.json(),
|
|
genRes.json()
|
|
]);
|
|
|
|
const iters = iterData.iterations || [];
|
|
setIterations(iters);
|
|
setGeneratedFiles(genData.files || []);
|
|
|
|
// Auto-select best design if available
|
|
if (iters.length > 0) {
|
|
const best = iters.find((i: IterationInfo) => i.type === 'best');
|
|
if (best) {
|
|
setSelectedIteration(best);
|
|
} else {
|
|
setSelectedIteration(iters[0]);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load iterations:', err);
|
|
setError('Failed to load iterations');
|
|
} finally {
|
|
setLoadingIterations(false);
|
|
}
|
|
}, [selectedStudy]);
|
|
|
|
// Load available insights (lazy - only when needed)
|
|
const loadAvailableInsights = useCallback(async () => {
|
|
if (!selectedStudy) return;
|
|
|
|
setLoadingInsights(true);
|
|
|
|
try {
|
|
const [availRes, genRes] = await Promise.all([
|
|
fetch(`/api/insights/studies/${selectedStudy.id}/available`),
|
|
fetch(`/api/insights/studies/${selectedStudy.id}/generated`)
|
|
]);
|
|
|
|
const [availData, genData] = await Promise.all([
|
|
availRes.json(),
|
|
genRes.json()
|
|
]);
|
|
|
|
setAvailableInsights(availData.insights || []);
|
|
setGeneratedFiles(genData.files || []);
|
|
} catch (err) {
|
|
console.error('Failed to load insights:', err);
|
|
} finally {
|
|
setLoadingInsights(false);
|
|
}
|
|
}, [selectedStudy]);
|
|
|
|
// Initial load
|
|
useEffect(() => {
|
|
loadIterations();
|
|
}, [loadIterations]);
|
|
|
|
// Load insights when moving to step 2
|
|
useEffect(() => {
|
|
if (step === 'select-insight' && availableInsights.length === 0) {
|
|
loadAvailableInsights();
|
|
}
|
|
}, [step, availableInsights.length, loadAvailableInsights]);
|
|
|
|
// Generate insight
|
|
const handleGenerate = async () => {
|
|
if (!selectedStudy || !selectedIteration || !selectedInsightType || generating) return;
|
|
|
|
setGenerating(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const res = await fetch(
|
|
`/api/insights/studies/${selectedStudy.id}/generate/${selectedInsightType}`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
iteration: selectedIteration.id
|
|
})
|
|
}
|
|
);
|
|
|
|
if (!res.ok) {
|
|
const errData = await res.json();
|
|
throw new Error(errData.detail || 'Generation failed');
|
|
}
|
|
|
|
const result: InsightResult = await res.json();
|
|
const insightInfo = availableInsights.find(i => i.type === selectedInsightType);
|
|
result.insight_name = insightInfo?.name || result.insight_type;
|
|
|
|
setActiveInsight(result);
|
|
setStep('view-result');
|
|
|
|
// Refresh generated files list
|
|
loadAvailableInsights();
|
|
} catch (err: any) {
|
|
setError(err.message || 'Failed to generate insight');
|
|
} finally {
|
|
setGenerating(false);
|
|
}
|
|
};
|
|
|
|
// Quick view existing generated file
|
|
const handleQuickView = async (file: GeneratedFile) => {
|
|
if (!selectedStudy) return;
|
|
window.open(`/api/insights/studies/${selectedStudy.id}/view/${file.insight_type}`, '_blank');
|
|
};
|
|
|
|
// Format timestamp
|
|
const formatTime = (ts: number) => {
|
|
const date = new Date(ts * 1000);
|
|
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
};
|
|
|
|
// Group insights by category
|
|
const groupedInsights = useMemo(() => {
|
|
const groups: Record<string, InsightInfo[]> = {};
|
|
|
|
availableInsights.forEach(insight => {
|
|
const cat = insight.category || 'other';
|
|
if (!groups[cat]) {
|
|
groups[cat] = [];
|
|
}
|
|
groups[cat].push(insight);
|
|
});
|
|
|
|
return groups;
|
|
}, [availableInsights]);
|
|
|
|
const getIcon = (type: string): LucideIcon => INSIGHT_ICONS[type] || Eye;
|
|
const getColorClass = (type: string) => INSIGHT_COLORS[type] || 'text-gray-400 bg-gray-500/10 border-gray-500/30';
|
|
|
|
// ============================================================================
|
|
// Render
|
|
// ============================================================================
|
|
if (!isInitialized || !selectedStudy) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
<div className="text-center">
|
|
<RefreshCw className="w-8 h-8 animate-spin text-dark-400 mx-auto mb-4" />
|
|
<p className="text-dark-400">Loading...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-full max-w-7xl mx-auto">
|
|
{/* Header with Breadcrumb */}
|
|
<header className="mb-6">
|
|
<div className="flex items-center gap-2 text-sm text-dark-400 mb-2">
|
|
<span className={step === 'select-iteration' ? 'text-primary-400 font-medium' : ''}>
|
|
1. Select Iteration
|
|
</span>
|
|
<ChevronRight className="w-4 h-4" />
|
|
<span className={step === 'select-insight' ? 'text-primary-400 font-medium' : ''}>
|
|
2. Choose Insight
|
|
</span>
|
|
<ChevronRight className="w-4 h-4" />
|
|
<span className={step === 'view-result' ? 'text-primary-400 font-medium' : ''}>
|
|
3. View Result
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">Study Insights</h1>
|
|
<p className="text-dark-400 text-sm">{selectedStudy.name || selectedStudy.id}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setStep('select-iteration');
|
|
setActiveInsight(null);
|
|
loadIterations();
|
|
}}
|
|
className="flex items-center gap-2 px-3 py-2 bg-dark-700 hover:bg-dark-600 text-white rounded-lg transition-colors text-sm"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Reset
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Error Banner */}
|
|
{error && (
|
|
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-lg flex items-center gap-3">
|
|
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0" />
|
|
<p className="text-red-400 flex-1">{error}</p>
|
|
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Step 1: Select Iteration */}
|
|
{step === 'select-iteration' && (
|
|
<div className="space-y-6">
|
|
<Card title="Select Data Source" className="p-0">
|
|
{loadingIterations ? (
|
|
<div className="p-8 text-center text-dark-400">
|
|
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
|
|
Scanning for iterations...
|
|
</div>
|
|
) : iterations.length === 0 ? (
|
|
<div className="p-8 text-center text-dark-400">
|
|
<Folder className="w-12 h-12 mx-auto mb-4 opacity-30" />
|
|
<p className="text-lg font-medium text-dark-300 mb-2">No Iterations Found</p>
|
|
<p className="text-sm">Run some optimization trials first to generate data.</p>
|
|
</div>
|
|
) : (
|
|
<div className="p-5 space-y-4">
|
|
{/* Best Design - Primary Option */}
|
|
{(() => {
|
|
const bestIter = iterations.find((i) => i.type === 'best');
|
|
if (!bestIter) return null;
|
|
return (
|
|
<button
|
|
onClick={() => setSelectedIteration(bestIter)}
|
|
className={`w-full p-4 flex items-center gap-4 rounded-lg border-2 transition-all ${
|
|
selectedIteration?.id === bestIter.id
|
|
? 'bg-green-500/10 border-green-500 shadow-lg shadow-green-500/10'
|
|
: 'bg-dark-750 border-dark-600 hover:border-green-500/50 hover:bg-dark-700'
|
|
}`}
|
|
>
|
|
<div className="p-3 rounded-lg bg-green-500/20 text-green-400">
|
|
<Zap className="w-6 h-6" />
|
|
</div>
|
|
<div className="flex-1 text-left">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold text-white text-lg">Best Design</span>
|
|
<span className="px-2 py-0.5 text-xs bg-green-500/20 text-green-400 rounded-full">
|
|
Recommended
|
|
</span>
|
|
</div>
|
|
<div className="text-sm text-dark-400 mt-1">
|
|
{bestIter.label || bestIter.id} • {formatTime(bestIter.modified)}
|
|
</div>
|
|
</div>
|
|
{selectedIteration?.id === bestIter.id && (
|
|
<CheckCircle className="w-6 h-6 text-green-400" />
|
|
)}
|
|
</button>
|
|
);
|
|
})()}
|
|
|
|
{/* Separator */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-1 border-t border-dark-600"></div>
|
|
<span className="text-sm text-dark-500">or select specific iteration</span>
|
|
<div className="flex-1 border-t border-dark-600"></div>
|
|
</div>
|
|
|
|
{/* Dropdown for other iterations */}
|
|
{(() => {
|
|
const regularIters = iterations.filter((i) => i.type !== 'best');
|
|
if (regularIters.length === 0) return null;
|
|
|
|
const selectedRegular = regularIters.find((i) => i.id === selectedIteration?.id);
|
|
|
|
return (
|
|
<div className="relative">
|
|
<div className="flex items-center gap-3">
|
|
<List className="w-5 h-5 text-dark-400" />
|
|
<select
|
|
value={selectedRegular?.id || ''}
|
|
onChange={(e) => {
|
|
const iter = regularIters.find((i) => i.id === e.target.value);
|
|
if (iter) setSelectedIteration(iter);
|
|
}}
|
|
className="flex-1 px-4 py-3 bg-dark-750 border border-dark-600 rounded-lg text-white appearance-none cursor-pointer hover:border-dark-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 transition-colors"
|
|
>
|
|
<option value="" disabled>
|
|
Choose from {regularIters.length} iteration{regularIters.length !== 1 ? 's' : ''}...
|
|
</option>
|
|
{regularIters.map((iter) => (
|
|
<option key={iter.id} value={iter.id}>
|
|
{iter.label || iter.id} ({iter.op2_file})
|
|
</option>
|
|
))}
|
|
</select>
|
|
<ChevronDown className="w-5 h-5 text-dark-400 absolute right-3 pointer-events-none" />
|
|
</div>
|
|
|
|
{/* Show selected iteration details */}
|
|
{selectedRegular && (
|
|
<div className="mt-3 p-3 bg-dark-700 rounded-lg border border-primary-500/30">
|
|
<div className="flex items-center gap-3">
|
|
<Folder className="w-5 h-5 text-primary-400" />
|
|
<div className="flex-1">
|
|
<div className="font-medium text-white">{selectedRegular.label || selectedRegular.id}</div>
|
|
<div className="text-sm text-dark-400">
|
|
{selectedRegular.op2_file} • {formatTime(selectedRegular.modified)}
|
|
</div>
|
|
</div>
|
|
<CheckCircle className="w-5 h-5 text-primary-400" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Continue Button */}
|
|
{selectedIteration && (
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={() => setStep('select-insight')}
|
|
className="flex items-center gap-2 px-6 py-3 bg-primary-600 hover:bg-primary-500 text-white rounded-lg font-medium transition-colors"
|
|
>
|
|
Continue
|
|
<ChevronRight className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Previously Generated - Quick Access */}
|
|
{generatedFiles.length > 0 && (
|
|
<Card title="Previously Generated" className="p-0 mt-6">
|
|
<div className="p-3 bg-dark-750 border-b border-dark-600">
|
|
<p className="text-sm text-dark-400">Quick access to existing visualizations</p>
|
|
</div>
|
|
<div className="divide-y divide-dark-600 max-h-48 overflow-y-auto">
|
|
{generatedFiles.slice(0, 5).map((file) => {
|
|
const Icon = getIcon(file.insight_type);
|
|
return (
|
|
<button
|
|
key={file.filename}
|
|
onClick={() => handleQuickView(file)}
|
|
className="w-full p-3 flex items-center gap-3 hover:bg-dark-750 transition-colors text-left"
|
|
>
|
|
<Icon className="w-4 h-4 text-dark-400" />
|
|
<span className="flex-1 text-sm text-white truncate">
|
|
{file.insight_type.replace(/_/g, ' ')}
|
|
</span>
|
|
<span className="text-xs text-dark-500">{file.size_kb} KB</span>
|
|
<ExternalLink className="w-4 h-4 text-dark-400" />
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Step 2: Select Insight Type */}
|
|
{step === 'select-insight' && (
|
|
<div className="space-y-6">
|
|
{/* Selection Summary */}
|
|
<div className="flex items-center gap-4 p-4 bg-dark-800 rounded-lg">
|
|
<div className="p-2 bg-primary-600/20 rounded-lg">
|
|
<Folder className="w-5 h-5 text-primary-400" />
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-dark-400">Selected Data Source</div>
|
|
<div className="font-medium text-white">{selectedIteration?.label || selectedIteration?.id}</div>
|
|
</div>
|
|
<button
|
|
onClick={() => setStep('select-iteration')}
|
|
className="ml-auto text-sm text-primary-400 hover:text-primary-300"
|
|
>
|
|
Change
|
|
</button>
|
|
</div>
|
|
|
|
<Card title="Choose Insight Type" className="p-0">
|
|
{loadingInsights ? (
|
|
<div className="p-8 text-center text-dark-400">
|
|
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
|
|
Loading available insights...
|
|
</div>
|
|
) : availableInsights.length === 0 ? (
|
|
<div className="p-8 text-center text-dark-400">
|
|
<Eye className="w-12 h-12 mx-auto mb-4 opacity-30" />
|
|
<p className="text-lg font-medium text-dark-300 mb-2">No Insights Available</p>
|
|
<p className="text-sm">The selected iteration may not have compatible data.</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-dark-600">
|
|
{Object.entries(groupedInsights).map(([category, insights]) => (
|
|
<div key={category} className={`border-l-2 ${CATEGORY_COLORS[category] || CATEGORY_COLORS.other}`}>
|
|
<div className="px-4 py-2 bg-dark-750/50 border-b border-dark-600">
|
|
<h4 className="text-sm font-medium text-dark-300 capitalize">
|
|
{category.replace(/_/g, ' ')}
|
|
</h4>
|
|
</div>
|
|
{insights.map((insight) => {
|
|
const Icon = getIcon(insight.type);
|
|
const colorClass = getColorClass(insight.type);
|
|
const isSelected = selectedInsightType === insight.type;
|
|
|
|
return (
|
|
<button
|
|
key={insight.type}
|
|
onClick={() => setSelectedInsightType(insight.type)}
|
|
className={`w-full p-4 flex items-center gap-4 hover:bg-dark-750 transition-colors text-left ${
|
|
isSelected ? 'bg-primary-600/10' : ''
|
|
}`}
|
|
>
|
|
<div className={`p-2.5 rounded-lg border ${colorClass}`}>
|
|
<Icon className="w-5 h-5" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium text-white">{insight.name}</div>
|
|
<div className="text-sm text-dark-400 mt-0.5">{insight.description}</div>
|
|
</div>
|
|
{isSelected && (
|
|
<CheckCircle className="w-5 h-5 text-primary-400 flex-shrink-0" />
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Generate Button */}
|
|
{selectedInsightType && (
|
|
<div className="flex justify-between items-center">
|
|
<button
|
|
onClick={() => setStep('select-iteration')}
|
|
className="text-dark-400 hover:text-white transition-colors"
|
|
>
|
|
Back
|
|
</button>
|
|
<button
|
|
onClick={handleGenerate}
|
|
disabled={generating}
|
|
className={`flex items-center gap-2 px-6 py-3 rounded-lg font-medium transition-colors ${
|
|
generating
|
|
? 'bg-dark-600 text-dark-400 cursor-wait'
|
|
: 'bg-primary-600 hover:bg-primary-500 text-white'
|
|
}`}
|
|
>
|
|
{generating ? (
|
|
<>
|
|
<RefreshCw className="w-5 h-5 animate-spin" />
|
|
Generating...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Play className="w-5 h-5" />
|
|
Generate Insight
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Step 3: View Result */}
|
|
{step === 'view-result' && activeInsight && (
|
|
<div className="space-y-6">
|
|
{/* Result Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={() => {
|
|
setStep('select-insight');
|
|
setActiveInsight(null);
|
|
}}
|
|
className="text-dark-400 hover:text-white transition-colors"
|
|
>
|
|
← Back
|
|
</button>
|
|
<div>
|
|
<h2 className="text-xl font-bold text-white">
|
|
{activeInsight.insight_name || activeInsight.insight_type.replace(/_/g, ' ')}
|
|
</h2>
|
|
<p className="text-sm text-dark-400">
|
|
Generated from {selectedIteration?.label || selectedIteration?.id}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{activeInsight.html_path && (
|
|
<button
|
|
onClick={() => window.open(`/api/insights/studies/${selectedStudy?.id}/view/${activeInsight.insight_type}`, '_blank')}
|
|
className="flex items-center gap-2 px-4 py-2 bg-dark-700 hover:bg-dark-600 text-white rounded-lg transition-colors"
|
|
>
|
|
<ExternalLink className="w-4 h-4" />
|
|
Open Full View
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setFullscreen(true)}
|
|
className="p-2 bg-dark-700 hover:bg-dark-600 text-white rounded-lg transition-colors"
|
|
title="Fullscreen"
|
|
>
|
|
<Maximize2 className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Stats */}
|
|
{activeInsight.summary && Object.keys(activeInsight.summary).length > 0 && (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
{Object.entries(activeInsight.summary)
|
|
.filter(([key]) => !key.startsWith('html_'))
|
|
.slice(0, 8)
|
|
.map(([key, value]) => (
|
|
<div key={key} className="bg-dark-800 rounded-lg p-4">
|
|
<div className="text-xs text-dark-400 uppercase truncate mb-1">
|
|
{key.replace(/_/g, ' ')}
|
|
</div>
|
|
<div className="text-lg font-mono text-white truncate">
|
|
{typeof value === 'number'
|
|
? value.toFixed(2)
|
|
: String(value)
|
|
}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Plotly Figure */}
|
|
<Card className="p-0 overflow-hidden">
|
|
{activeInsight.plotly_figure ? (
|
|
<div className="bg-dark-900" style={{ height: '600px' }}>
|
|
<Plot
|
|
data={activeInsight.plotly_figure.data}
|
|
layout={{
|
|
...activeInsight.plotly_figure.layout,
|
|
autosize: true,
|
|
margin: { l: 60, r: 60, t: 60, b: 60 },
|
|
paper_bgcolor: '#111827',
|
|
plot_bgcolor: '#1f2937',
|
|
font: { color: 'white' }
|
|
}}
|
|
config={{
|
|
responsive: true,
|
|
displayModeBar: true,
|
|
displaylogo: false
|
|
}}
|
|
style={{ width: '100%', height: '100%' }}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center h-64 text-dark-400 p-8">
|
|
<CheckCircle className="w-12 h-12 text-green-400 mb-4" />
|
|
<p className="text-lg font-medium text-white mb-2">Insight Generated Successfully</p>
|
|
<p className="text-sm text-center">
|
|
This insight generates HTML files. Click "Open Full View" to see the visualization.
|
|
</p>
|
|
{activeInsight.summary?.html_files && (
|
|
<div className="mt-4 text-sm">
|
|
<p className="text-dark-400 mb-2">Generated files:</p>
|
|
<ul className="space-y-1">
|
|
{(activeInsight.summary.html_files as string[]).slice(0, 4).map((f: string, i: number) => (
|
|
<li key={i} className="text-dark-300">
|
|
{f.split(/[/\\]/).pop()}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Generate Another */}
|
|
<div className="flex justify-center">
|
|
<button
|
|
onClick={() => {
|
|
setStep('select-insight');
|
|
setActiveInsight(null);
|
|
setSelectedInsightType(null);
|
|
}}
|
|
className="flex items-center gap-2 px-4 py-2 bg-dark-700 hover:bg-dark-600 text-white rounded-lg transition-colors"
|
|
>
|
|
<Lightbulb className="w-4 h-4" />
|
|
Generate Another Insight
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Fullscreen Modal */}
|
|
{fullscreen && activeInsight?.plotly_figure && (
|
|
<div className="fixed inset-0 z-50 bg-dark-900 flex flex-col">
|
|
<div className="flex items-center justify-between p-4 border-b border-dark-600">
|
|
<h2 className="text-xl font-bold text-white">
|
|
{activeInsight.insight_name || activeInsight.insight_type.replace(/_/g, ' ')}
|
|
</h2>
|
|
<button
|
|
onClick={() => setFullscreen(false)}
|
|
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
|
|
>
|
|
<X className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 p-4">
|
|
<Plot
|
|
data={activeInsight.plotly_figure.data}
|
|
layout={{
|
|
...activeInsight.plotly_figure.layout,
|
|
autosize: true,
|
|
paper_bgcolor: '#111827',
|
|
plot_bgcolor: '#1f2937',
|
|
font: { color: 'white' }
|
|
}}
|
|
config={{
|
|
responsive: true,
|
|
displayModeBar: true,
|
|
displaylogo: false
|
|
}}
|
|
style={{ width: '100%', height: '100%' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|