feat: Add Insights tab to dashboard for physics visualizations

Dashboard integration for Study Insights module (SYS_16):
- Backend: New /api/insights/ routes for generating and viewing insights
- Frontend: New Insights.tsx page with Plotly visualization
- Navigation: Added Insights tab between Analysis and Results

Available insight types:
- Zernike WFE (wavefront error for mirrors)
- Stress Field (Von Mises stress contours)
- Modal Analysis (natural frequencies/mode shapes)
- Thermal Field (temperature distribution)
- Design Space (parameter-objective exploration)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-21 13:28:51 -05:00
parent 9aa5f6eb8c
commit d089003ced
5 changed files with 680 additions and 2 deletions

View File

@@ -8,6 +8,7 @@ import Home from './pages/Home';
import Setup from './pages/Setup';
import Dashboard from './pages/Dashboard';
import Analysis from './pages/Analysis';
import Insights from './pages/Insights';
import Results from './pages/Results';
const queryClient = new QueryClient({
@@ -34,6 +35,7 @@ function App() {
<Route path="setup" element={<Setup />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="analysis" element={<Analysis />} />
<Route path="insights" element={<Insights />} />
<Route path="results" element={<Results />} />
</Route>
</Routes>

View File

@@ -11,7 +11,8 @@ import {
CheckCircle,
Clock,
Zap,
Terminal
Terminal,
Eye
} from 'lucide-react';
import clsx from 'clsx';
import { useStudy } from '../../context/StudyContext';
@@ -63,6 +64,7 @@ export const Sidebar = () => {
{ to: '/setup', icon: Settings, label: 'Setup' },
{ to: '/dashboard', icon: Activity, label: 'Live Tracker' },
{ to: '/analysis', icon: TrendingUp, label: 'Analysis' },
{ to: '/insights', icon: Eye, label: 'Insights' },
{ to: '/results', icon: FileText, label: 'Results' },
]
: [

View File

@@ -0,0 +1,452 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Eye,
RefreshCw,
Download,
Maximize2,
X,
Activity,
Thermometer,
Waves,
Grid3X3,
Box,
AlertCircle,
CheckCircle,
Clock,
FileText
} from 'lucide-react';
import { useStudy } from '../context/StudyContext';
import { Card } from '../components/common/Card';
import Plot from 'react-plotly.js';
interface InsightInfo {
type: string;
name: string;
description: 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;
plotly_figure: any;
summary: Record<string, any>;
html_path: string | null;
}
const INSIGHT_ICONS: Record<string, React.ElementType> = {
zernike_wfe: 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',
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'
};
export default function Insights() {
const navigate = useNavigate();
const { selectedStudy, isInitialized } = useStudy();
const [availableInsights, setAvailableInsights] = useState<InsightInfo[]>([]);
const [generatedFiles, setGeneratedFiles] = useState<GeneratedFile[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Active insight for display
const [activeInsight, setActiveInsight] = useState<InsightResult | null>(null);
const [fullscreen, setFullscreen] = useState(false);
// Redirect if no study
useEffect(() => {
if (isInitialized && !selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate, isInitialized]);
// Load available insights and generated files
const loadInsights = useCallback(async () => {
if (!selectedStudy) return;
setLoading(true);
setError(null);
try {
// Load available insights
const availRes = await fetch(`/api/insights/studies/${selectedStudy.id}/insights/available`);
const availData = await availRes.json();
setAvailableInsights(availData.insights || []);
// Load previously generated files
const genRes = await fetch(`/api/insights/studies/${selectedStudy.id}/insights/generated`);
const genData = await genRes.json();
setGeneratedFiles(genData.files || []);
} catch (err) {
console.error('Failed to load insights:', err);
setError('Failed to load insights data');
} finally {
setLoading(false);
}
}, [selectedStudy]);
useEffect(() => {
loadInsights();
}, [loadInsights]);
// Generate an insight
const handleGenerate = async (insightType: string) => {
if (!selectedStudy || generating) return;
setGenerating(insightType);
setError(null);
try {
const res = await fetch(
`/api/insights/studies/${selectedStudy.id}/insights/generate/${insightType}`,
{ method: 'POST' }
);
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.detail || 'Generation failed');
}
const result: InsightResult = await res.json();
setActiveInsight(result);
// Refresh file list
loadInsights();
} catch (err: any) {
setError(err.message || 'Failed to generate insight');
} finally {
setGenerating(null);
}
};
// View existing insight
const handleViewExisting = async (file: GeneratedFile) => {
if (!selectedStudy) return;
setGenerating(file.insight_type);
try {
const res = await fetch(
`/api/insights/studies/${selectedStudy.id}/insights/generate/${file.insight_type}`,
{ method: 'POST' }
);
if (!res.ok) {
throw new Error('Failed to load insight');
}
const result: InsightResult = await res.json();
setActiveInsight(result);
} catch (err: any) {
setError(err.message || 'Failed to load insight');
} finally {
setGenerating(null);
}
};
// Open HTML in new tab
const handleOpenHtml = (file: GeneratedFile) => {
if (!selectedStudy) return;
window.open(`/api/insights/studies/${selectedStudy.id}/insights/view/${file.insight_type}`, '_blank');
};
const getIcon = (type: string) => {
const Icon = INSIGHT_ICONS[type] || Eye;
return Icon;
};
const getColorClass = (type: string) => {
return INSIGHT_COLORS[type] || 'text-gray-400 bg-gray-500/10 border-gray-500/30';
};
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">
{/* Header */}
<header className="mb-6 flex items-center justify-between border-b border-dark-600 pb-4">
<div>
<h1 className="text-2xl font-bold text-primary-400">Insights</h1>
<p className="text-dark-400 text-sm">
Physics visualizations for {selectedStudy.name || selectedStudy.id}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={loadInsights}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 bg-dark-700 hover:bg-dark-600 text-white rounded-lg transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</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">{error}</p>
<button onClick={() => setError(null)} className="ml-auto text-red-400 hover:text-red-300">
<X className="w-4 h-4" />
</button>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Panel: Available Insights */}
<div className="lg:col-span-1 space-y-4">
<Card title="Available Insights" className="p-0">
{loading ? (
<div className="p-6 text-center text-dark-400">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
Loading...
</div>
) : availableInsights.length === 0 ? (
<div className="p-6 text-center text-dark-400">
<Eye className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>No insights available for this study.</p>
<p className="text-xs mt-1">Run some trials first.</p>
</div>
) : (
<div className="divide-y divide-dark-600">
{availableInsights.map((insight) => {
const Icon = getIcon(insight.type);
const colorClass = getColorClass(insight.type);
const isGenerating = generating === insight.type;
return (
<div key={insight.type} className="p-4 hover:bg-dark-750 transition-colors">
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg border ${colorClass}`}>
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-white">{insight.name}</h3>
<p className="text-xs text-dark-400 mt-0.5">{insight.description}</p>
</div>
</div>
<button
onClick={() => handleGenerate(insight.type)}
disabled={isGenerating}
className={`mt-3 w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
isGenerating
? 'bg-dark-600 text-dark-400 cursor-wait'
: 'bg-primary-600 hover:bg-primary-500 text-white'
}`}
>
{isGenerating ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Generating...
</>
) : (
<>
<Eye className="w-4 h-4" />
Generate
</>
)}
</button>
</div>
);
})}
</div>
)}
</Card>
{/* Previously Generated */}
{generatedFiles.length > 0 && (
<Card title="Generated Files" className="p-0">
<div className="divide-y divide-dark-600 max-h-64 overflow-y-auto">
{generatedFiles.map((file) => {
const Icon = getIcon(file.insight_type);
return (
<div
key={file.filename}
className="p-3 hover:bg-dark-750 transition-colors flex items-center gap-3"
>
<Icon className="w-4 h-4 text-dark-400" />
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{file.insight_type}</p>
<p className="text-xs text-dark-500">{file.size_kb} KB</p>
</div>
<div className="flex gap-1">
<button
onClick={() => handleViewExisting(file)}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-600 rounded transition-colors"
title="View in dashboard"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => handleOpenHtml(file)}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-600 rounded transition-colors"
title="Open in new tab"
>
<Maximize2 className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
</Card>
)}
</div>
{/* Right Panel: Visualization */}
<div className="lg:col-span-2">
{activeInsight ? (
<Card
title={
<div className="flex items-center justify-between">
<span>{activeInsight.insight_type.replace('_', ' ').toUpperCase()}</span>
<div className="flex gap-2">
<button
onClick={() => setFullscreen(true)}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-600 rounded transition-colors"
title="Fullscreen"
>
<Maximize2 className="w-4 h-4" />
</button>
<button
onClick={() => setActiveInsight(null)}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-600 rounded transition-colors"
title="Close"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
}
className="h-full"
>
{/* Summary Stats */}
{activeInsight.summary && Object.keys(activeInsight.summary).length > 0 && (
<div className="mb-4 grid grid-cols-2 md:grid-cols-4 gap-3">
{Object.entries(activeInsight.summary).slice(0, 8).map(([key, value]) => (
<div key={key} className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 uppercase truncate">{key.replace(/_/g, ' ')}</div>
<div className="text-lg font-mono text-white truncate">
{typeof value === 'number'
? value.toExponential ? value.toExponential(3) : value
: String(value)
}
</div>
</div>
))}
</div>
)}
{/* Plotly Figure */}
{activeInsight.plotly_figure ? (
<div className="bg-dark-900 rounded-lg overflow-hidden" style={{ height: '500px' }}>
<Plot
data={activeInsight.plotly_figure.data}
layout={{
...activeInsight.plotly_figure.layout,
autosize: true,
margin: { l: 50, r: 50, t: 50, b: 50 },
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 items-center justify-center h-64 text-dark-400">
<p>No visualization data available</p>
</div>
)}
</Card>
) : (
<Card className="h-full flex items-center justify-center min-h-[400px]">
<div className="text-center text-dark-400">
<Eye className="w-12 h-12 mx-auto mb-4 opacity-30" />
<h3 className="text-lg font-medium text-dark-300 mb-2">No Insight Selected</h3>
<p className="text-sm">
Select an insight type from the left panel to generate a visualization.
</p>
</div>
</Card>
)}
</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_type.replace('_', ' ').toUpperCase()}
</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>
);
}