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:
@@ -12,7 +12,7 @@ import sys
|
|||||||
# Add parent directory to path to import optimization_engine
|
# Add parent directory to path to import optimization_engine
|
||||||
sys.path.append(str(Path(__file__).parent.parent.parent.parent))
|
sys.path.append(str(Path(__file__).parent.parent.parent.parent))
|
||||||
|
|
||||||
from api.routes import optimization, claude, terminal
|
from api.routes import optimization, claude, terminal, insights
|
||||||
from api.websocket import optimization_stream
|
from api.websocket import optimization_stream
|
||||||
|
|
||||||
# Create FastAPI app
|
# Create FastAPI app
|
||||||
@@ -36,6 +36,7 @@ app.include_router(optimization.router, prefix="/api/optimization", tags=["optim
|
|||||||
app.include_router(optimization_stream.router, prefix="/api/ws", tags=["websocket"])
|
app.include_router(optimization_stream.router, prefix="/api/ws", tags=["websocket"])
|
||||||
app.include_router(claude.router, prefix="/api/claude", tags=["claude"])
|
app.include_router(claude.router, prefix="/api/claude", tags=["claude"])
|
||||||
app.include_router(terminal.router, prefix="/api/terminal", tags=["terminal"])
|
app.include_router(terminal.router, prefix="/api/terminal", tags=["terminal"])
|
||||||
|
app.include_router(insights.router, prefix="/api/insights", tags=["insights"])
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
|
|||||||
221
atomizer-dashboard/backend/api/routes/insights.py
Normal file
221
atomizer-dashboard/backend/api/routes/insights.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"""
|
||||||
|
Study Insights API endpoints
|
||||||
|
Provides physics-focused visualizations for completed optimization trials
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse, HTMLResponse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
sys.path.append(str(Path(__file__).parent.parent.parent.parent.parent))
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Base studies directory
|
||||||
|
STUDIES_DIR = Path(__file__).parent.parent.parent.parent.parent / "studies"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_study_path(study_id: str) -> Path:
|
||||||
|
"""Find study folder by scanning all topic directories."""
|
||||||
|
# First check direct path (backwards compatibility)
|
||||||
|
direct_path = STUDIES_DIR / study_id
|
||||||
|
if direct_path.exists() and direct_path.is_dir():
|
||||||
|
if (direct_path / "1_setup").exists() or (direct_path / "optimization_config.json").exists():
|
||||||
|
return direct_path
|
||||||
|
|
||||||
|
# Scan topic folders for nested structure
|
||||||
|
for topic_dir in STUDIES_DIR.iterdir():
|
||||||
|
if topic_dir.is_dir() and not topic_dir.name.startswith('.'):
|
||||||
|
study_dir = topic_dir / study_id
|
||||||
|
if study_dir.exists() and study_dir.is_dir():
|
||||||
|
if (study_dir / "1_setup").exists() or (study_dir / "optimization_config.json").exists():
|
||||||
|
return study_dir
|
||||||
|
|
||||||
|
raise HTTPException(status_code=404, detail=f"Study not found: {study_id}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/studies/{study_id}/insights/available")
|
||||||
|
async def list_available_insights(study_id: str):
|
||||||
|
"""List all insight types that can be generated for this study."""
|
||||||
|
try:
|
||||||
|
study_path = resolve_study_path(study_id)
|
||||||
|
|
||||||
|
# Import insights module
|
||||||
|
from optimization_engine.insights import list_available_insights as get_available
|
||||||
|
|
||||||
|
available = get_available(study_path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"study_id": study_id,
|
||||||
|
"insights": available
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except ImportError as e:
|
||||||
|
return {
|
||||||
|
"study_id": study_id,
|
||||||
|
"insights": [],
|
||||||
|
"error": f"Insights module not available: {str(e)}"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/studies/{study_id}/insights/all")
|
||||||
|
async def list_all_insights():
|
||||||
|
"""List all registered insight types (regardless of availability for any study)."""
|
||||||
|
try:
|
||||||
|
from optimization_engine.insights import list_insights
|
||||||
|
|
||||||
|
return {
|
||||||
|
"insights": list_insights()
|
||||||
|
}
|
||||||
|
except ImportError as e:
|
||||||
|
return {
|
||||||
|
"insights": [],
|
||||||
|
"error": f"Insights module not available: {str(e)}"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/studies/{study_id}/insights/generate/{insight_type}")
|
||||||
|
async def generate_insight(study_id: str, insight_type: str, trial_id: Optional[int] = None):
|
||||||
|
"""Generate a specific insight visualization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
study_id: Study identifier
|
||||||
|
insight_type: Type of insight (e.g., 'zernike_wfe', 'stress_field', 'design_space')
|
||||||
|
trial_id: Optional specific trial to analyze (defaults to best trial)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with plotly_figure data and summary statistics
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
study_path = resolve_study_path(study_id)
|
||||||
|
|
||||||
|
from optimization_engine.insights import get_insight, InsightConfig
|
||||||
|
|
||||||
|
insight = get_insight(insight_type, study_path)
|
||||||
|
if insight is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unknown insight type: {insight_type}")
|
||||||
|
|
||||||
|
if not insight.can_generate():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Cannot generate {insight_type}: required data not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure insight
|
||||||
|
config = InsightConfig(trial_id=trial_id)
|
||||||
|
|
||||||
|
# Generate
|
||||||
|
result = insight.generate(config)
|
||||||
|
|
||||||
|
if not result.success:
|
||||||
|
raise HTTPException(status_code=500, detail=result.error or "Generation failed")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"insight_type": insight_type,
|
||||||
|
"study_id": study_id,
|
||||||
|
"html_path": str(result.html_path) if result.html_path else None,
|
||||||
|
"plotly_figure": result.plotly_figure,
|
||||||
|
"summary": result.summary
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/studies/{study_id}/insights/view/{insight_type}")
|
||||||
|
async def view_insight_html(study_id: str, insight_type: str):
|
||||||
|
"""Get the HTML content for an insight (for iframe embedding).
|
||||||
|
|
||||||
|
Returns the most recent generated HTML file for this insight type,
|
||||||
|
or generates one if none exists.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
study_path = resolve_study_path(study_id)
|
||||||
|
insights_dir = study_path / "3_insights"
|
||||||
|
|
||||||
|
# Look for existing HTML files
|
||||||
|
if insights_dir.exists():
|
||||||
|
pattern = f"{insight_type}_*.html"
|
||||||
|
existing = list(insights_dir.glob(pattern))
|
||||||
|
if existing:
|
||||||
|
# Return most recent
|
||||||
|
newest = max(existing, key=lambda p: p.stat().st_mtime)
|
||||||
|
return HTMLResponse(content=newest.read_text(encoding='utf-8'))
|
||||||
|
|
||||||
|
# No existing file - generate one
|
||||||
|
from optimization_engine.insights import get_insight, InsightConfig
|
||||||
|
|
||||||
|
insight = get_insight(insight_type, study_path)
|
||||||
|
if insight is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unknown insight type: {insight_type}")
|
||||||
|
|
||||||
|
if not insight.can_generate():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Cannot generate {insight_type}: required data not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = insight.generate(InsightConfig())
|
||||||
|
|
||||||
|
if result.success and result.html_path:
|
||||||
|
return HTMLResponse(content=result.html_path.read_text(encoding='utf-8'))
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=500, detail=result.error or "Generation failed")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/studies/{study_id}/insights/generated")
|
||||||
|
async def list_generated_insights(study_id: str):
|
||||||
|
"""List all previously generated insight HTML files for a study."""
|
||||||
|
try:
|
||||||
|
study_path = resolve_study_path(study_id)
|
||||||
|
insights_dir = study_path / "3_insights"
|
||||||
|
|
||||||
|
if not insights_dir.exists():
|
||||||
|
return {"study_id": study_id, "files": []}
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for html_file in insights_dir.glob("*.html"):
|
||||||
|
# Parse insight type from filename (e.g., "zernike_wfe_20251220_143022.html")
|
||||||
|
name = html_file.stem
|
||||||
|
parts = name.rsplit('_', 2) # Split from right to get type and timestamp
|
||||||
|
|
||||||
|
insight_type = parts[0] if len(parts) >= 3 else name
|
||||||
|
timestamp = f"{parts[-2]}_{parts[-1]}" if len(parts) >= 3 else None
|
||||||
|
|
||||||
|
files.append({
|
||||||
|
"filename": html_file.name,
|
||||||
|
"insight_type": insight_type,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"size_kb": round(html_file.stat().st_size / 1024, 1),
|
||||||
|
"modified": html_file.stat().st_mtime
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by modification time (newest first)
|
||||||
|
files.sort(key=lambda x: x['modified'], reverse=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"study_id": study_id,
|
||||||
|
"files": files
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -8,6 +8,7 @@ import Home from './pages/Home';
|
|||||||
import Setup from './pages/Setup';
|
import Setup from './pages/Setup';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import Analysis from './pages/Analysis';
|
import Analysis from './pages/Analysis';
|
||||||
|
import Insights from './pages/Insights';
|
||||||
import Results from './pages/Results';
|
import Results from './pages/Results';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -34,6 +35,7 @@ function App() {
|
|||||||
<Route path="setup" element={<Setup />} />
|
<Route path="setup" element={<Setup />} />
|
||||||
<Route path="dashboard" element={<Dashboard />} />
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
<Route path="analysis" element={<Analysis />} />
|
<Route path="analysis" element={<Analysis />} />
|
||||||
|
<Route path="insights" element={<Insights />} />
|
||||||
<Route path="results" element={<Results />} />
|
<Route path="results" element={<Results />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
Clock,
|
||||||
Zap,
|
Zap,
|
||||||
Terminal
|
Terminal,
|
||||||
|
Eye
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useStudy } from '../../context/StudyContext';
|
import { useStudy } from '../../context/StudyContext';
|
||||||
@@ -63,6 +64,7 @@ export const Sidebar = () => {
|
|||||||
{ to: '/setup', icon: Settings, label: 'Setup' },
|
{ to: '/setup', icon: Settings, label: 'Setup' },
|
||||||
{ to: '/dashboard', icon: Activity, label: 'Live Tracker' },
|
{ to: '/dashboard', icon: Activity, label: 'Live Tracker' },
|
||||||
{ to: '/analysis', icon: TrendingUp, label: 'Analysis' },
|
{ to: '/analysis', icon: TrendingUp, label: 'Analysis' },
|
||||||
|
{ to: '/insights', icon: Eye, label: 'Insights' },
|
||||||
{ to: '/results', icon: FileText, label: 'Results' },
|
{ to: '/results', icon: FileText, label: 'Results' },
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
|
|||||||
452
atomizer-dashboard/frontend/src/pages/Insights.tsx
Normal file
452
atomizer-dashboard/frontend/src/pages/Insights.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user