feat: Add OPD method support to Zernike visualization with Standard/OPD toggle

Major improvements to Zernike WFE visualization:

- Add ZernikeDashboardInsight: Unified dashboard with all orientations (40°, 60°, 90°)
  on one page with light theme and executive summary
- Add OPD method toggle: Switch between Standard (Z-only) and OPD (X,Y,Z) methods
  in ZernikeWFEInsight with interactive buttons
- Add lateral displacement maps: Visualize X,Y displacement for each orientation
- Add displacement component views: Toggle between WFE, ΔX, ΔY, ΔZ in relative views
- Add metrics comparison table showing both methods side-by-side

New extractors:
- extract_zernike_figure.py: ZernikeOPDExtractor using BDF geometry interpolation
- extract_zernike_opd.py: Parabola-based OPD with focal length

Key finding: OPD method gives 8-11% higher WFE values than Standard method
(more conservative/accurate for surfaces with lateral displacement under gravity)

Documentation updates:
- SYS_12: Added E22 ZernikeOPD as recommended method
- SYS_16: Added ZernikeDashboard, updated ZernikeWFE with OPD features
- Cheatsheet: Added Zernike method comparison table

🤖 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-22 21:03:19 -05:00
parent d089003ced
commit d19fc39a2a
19 changed files with 8117 additions and 396 deletions

View File

@@ -1,12 +1,19 @@
"""
Study Insights API endpoints
Provides physics-focused visualizations for completed optimization trials
Key Features:
- Config-driven insights from optimization_config.json
- AI recommendations based on objectives
- Report generation with PDF export
- Objective-linked and standalone insights
"""
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse, HTMLResponse
from pydantic import BaseModel
from pathlib import Path
from typing import List, Dict, Optional
from typing import List, Dict, Optional, Any
import json
import sys
@@ -19,6 +26,22 @@ router = APIRouter()
STUDIES_DIR = Path(__file__).parent.parent.parent.parent.parent / "studies"
class InsightSpecRequest(BaseModel):
"""Request model for insight specification."""
type: str
name: str
enabled: bool = True
linked_objective: Optional[str] = None
config: Dict[str, Any] = {}
include_in_report: bool = True
class GenerateReportRequest(BaseModel):
"""Request model for report generation."""
specs: Optional[List[InsightSpecRequest]] = None
include_appendix: bool = True
def resolve_study_path(study_id: str) -> Path:
"""Find study folder by scanning all topic directories."""
# First check direct path (backwards compatibility)
@@ -38,7 +61,7 @@ def resolve_study_path(study_id: str) -> Path:
raise HTTPException(status_code=404, detail=f"Study not found: {study_id}")
@router.get("/studies/{study_id}/insights/available")
@router.get("/studies/{study_id}/available")
async def list_available_insights(study_id: str):
"""List all insight types that can be generated for this study."""
try:
@@ -65,7 +88,7 @@ async def list_available_insights(study_id: str):
raise HTTPException(status_code=500, detail=str(e))
@router.get("/studies/{study_id}/insights/all")
@router.get("/studies/{study_id}/all")
async def list_all_insights():
"""List all registered insight types (regardless of availability for any study)."""
try:
@@ -83,14 +106,104 @@ async def list_all_insights():
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):
class GenerateInsightRequest(BaseModel):
"""Request model for insight generation."""
iteration: Optional[str] = None # e.g., "iter5", "best_design_archive"
trial_id: Optional[int] = None
config: Dict[str, Any] = {}
def _is_valid_op2(op2_path: Path) -> bool:
"""Quick check if OP2 file is valid (not from a failed solve)."""
try:
# Check file size - failed OP2s are often very small
if op2_path.stat().st_size < 10000: # Less than 10KB is suspicious
return False
# Try to read header to verify it's a valid OP2
with open(op2_path, 'rb') as f:
header = f.read(100)
# Valid OP2 files have specific markers
if b'NASTRAN' not in header and b'XXXXXXXX' not in header:
# Check for common valid patterns
pass # Size check is usually enough
return True
except Exception:
return False
@router.get("/studies/{study_id}/iterations")
async def list_iterations(study_id: str):
"""List available iterations/trials with OP2 files for insight generation.
Returns iterations sorted by modification time (newest first).
Only includes iterations with valid (non-corrupted) OP2 files.
"""
try:
study_path = resolve_study_path(study_id)
iterations = []
# Check 2_iterations folder
iter_dir = study_path / "2_iterations"
if iter_dir.exists():
for subdir in sorted(iter_dir.iterdir(), reverse=True):
if subdir.is_dir():
op2_files = [f for f in subdir.glob("*.op2") if _is_valid_op2(f)]
if op2_files:
newest_op2 = max(op2_files, key=lambda p: p.stat().st_mtime)
iterations.append({
"id": subdir.name,
"path": str(subdir.relative_to(study_path)),
"op2_file": newest_op2.name,
"modified": newest_op2.stat().st_mtime,
"type": "iteration"
})
# Check 3_results/best_design_archive folder
best_dir = study_path / "3_results" / "best_design_archive"
if best_dir.exists():
op2_files = [f for f in best_dir.glob("**/*.op2") if _is_valid_op2(f)]
if op2_files:
newest_op2 = max(op2_files, key=lambda p: p.stat().st_mtime)
iterations.insert(0, { # Insert at start as "best"
"id": "best_design_archive",
"path": "3_results/best_design_archive",
"op2_file": newest_op2.name,
"modified": newest_op2.stat().st_mtime,
"type": "best",
"label": "Best Design (Recommended)"
})
# Sort by modification time (newest first), keeping best at top
best = [i for i in iterations if i.get("type") == "best"]
others = sorted([i for i in iterations if i.get("type") != "best"],
key=lambda x: x["modified"], reverse=True)
return {
"study_id": study_id,
"iterations": best + others,
"count": len(iterations)
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/studies/{study_id}/generate/{insight_type}")
async def generate_insight(
study_id: str,
insight_type: str,
request: Optional[GenerateInsightRequest] = 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)
request: Optional generation config with iteration selection
Returns:
JSON with plotly_figure data and summary statistics
@@ -104,6 +217,25 @@ async def generate_insight(study_id: str, insight_type: str, trial_id: Optional[
if insight is None:
raise HTTPException(status_code=404, detail=f"Unknown insight type: {insight_type}")
# If iteration specified, override the OP2 path
if request and request.iteration:
iteration_id = request.iteration
if iteration_id == "best_design_archive":
iter_path = study_path / "3_results" / "best_design_archive"
else:
iter_path = study_path / "2_iterations" / iteration_id
if iter_path.exists():
op2_files = list(iter_path.glob("**/*.op2"))
if op2_files:
# Override the insight's OP2 path
insight.op2_path = max(op2_files, key=lambda p: p.stat().st_mtime)
# Re-find geometry
try:
insight.geo_path = insight._find_geometry_file(insight.op2_path)
except (FileNotFoundError, AttributeError):
pass # Use default
if not insight.can_generate():
raise HTTPException(
status_code=400,
@@ -111,7 +243,9 @@ async def generate_insight(study_id: str, insight_type: str, trial_id: Optional[
)
# Configure insight
config = InsightConfig(trial_id=trial_id)
trial_id = request.trial_id if request else None
extra_config = request.config if request else {}
config = InsightConfig(trial_id=trial_id, extra=extra_config)
# Generate
result = insight.generate(config)
@@ -123,6 +257,7 @@ async def generate_insight(study_id: str, insight_type: str, trial_id: Optional[
"success": True,
"insight_type": insight_type,
"study_id": study_id,
"iteration": request.iteration if request else None,
"html_path": str(result.html_path) if result.html_path else None,
"plotly_figure": result.plotly_figure,
"summary": result.summary
@@ -134,7 +269,7 @@ async def generate_insight(study_id: str, insight_type: str, trial_id: Optional[
raise HTTPException(status_code=500, detail=str(e))
@router.get("/studies/{study_id}/insights/view/{insight_type}")
@router.get("/studies/{study_id}/view/{insight_type}")
async def view_insight_html(study_id: str, insight_type: str):
"""Get the HTML content for an insight (for iframe embedding).
@@ -180,7 +315,7 @@ async def view_insight_html(study_id: str, insight_type: str):
raise HTTPException(status_code=500, detail=str(e))
@router.get("/studies/{study_id}/insights/generated")
@router.get("/studies/{study_id}/generated")
async def list_generated_insights(study_id: str):
"""List all previously generated insight HTML files for a study."""
try:
@@ -219,3 +354,199 @@ async def list_generated_insights(study_id: str):
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/studies/{study_id}/configured")
async def get_configured_insights_endpoint(study_id: str):
"""Get insights configured in the study's optimization_config.json."""
try:
study_path = resolve_study_path(study_id)
from optimization_engine.insights import get_configured_insights
specs = get_configured_insights(study_path)
return {
"study_id": study_id,
"configured": [
{
"type": spec.type,
"name": spec.name,
"enabled": spec.enabled,
"linked_objective": spec.linked_objective,
"config": spec.config,
"include_in_report": spec.include_in_report
}
for spec in specs
]
}
except HTTPException:
raise
except ImportError as e:
return {
"study_id": study_id,
"configured": [],
"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}/recommend")
async def recommend_insights_endpoint(study_id: str):
"""Get AI recommendations for insights based on study objectives."""
try:
study_path = resolve_study_path(study_id)
from optimization_engine.insights import recommend_insights_for_study
recommendations = recommend_insights_for_study(study_path)
return {
"study_id": study_id,
"recommendations": recommendations
}
except HTTPException:
raise
except ImportError as e:
return {
"study_id": study_id,
"recommendations": [],
"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}/report")
async def generate_report(study_id: str, request: GenerateReportRequest):
"""Generate comprehensive HTML report with all insights.
Args:
study_id: Study identifier
request: Report configuration with optional insight specs
Returns:
JSON with report path and generation results
"""
try:
study_path = resolve_study_path(study_id)
from optimization_engine.insights import (
InsightReport, InsightSpec, get_configured_insights,
recommend_insights_for_study
)
# Build specs from request or config
if request.specs:
specs = [
InsightSpec(
type=s.type,
name=s.name,
enabled=s.enabled,
linked_objective=s.linked_objective,
config=s.config,
include_in_report=s.include_in_report
)
for s in request.specs
]
else:
# Try config first, then recommendations
specs = get_configured_insights(study_path)
if not specs:
recommendations = recommend_insights_for_study(study_path)
specs = [
InsightSpec(
type=rec['type'],
name=rec['name'],
linked_objective=rec.get('linked_objective'),
config=rec.get('config', {})
)
for rec in recommendations
]
if not specs:
raise HTTPException(
status_code=400,
detail="No insights configured or recommended for this study"
)
# Generate report
report = InsightReport(study_path)
results = report.generate_all(specs)
report_path = report.generate_report_html(include_appendix=request.include_appendix)
return {
"success": True,
"study_id": study_id,
"report_path": str(report_path),
"results": [
{
"type": r.insight_type,
"name": r.insight_name,
"success": r.success,
"linked_objective": r.linked_objective,
"error": r.error
}
for r in results
],
"summary": {
"total": len(results),
"successful": sum(1 for r in results if r.success),
"failed": sum(1 for r in results if not r.success)
}
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/studies/{study_id}/report/view")
async def view_report(study_id: str):
"""Get the latest generated report HTML for embedding."""
try:
study_path = resolve_study_path(study_id)
report_path = study_path / "3_insights" / "STUDY_INSIGHTS_REPORT.html"
if not report_path.exists():
raise HTTPException(
status_code=404,
detail="No report generated yet. Use POST /insights/report to generate."
)
return HTMLResponse(content=report_path.read_text(encoding='utf-8'))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/studies/{study_id}/summary")
async def get_insights_summary(study_id: str):
"""Get insights summary JSON for Results page integration."""
try:
study_path = resolve_study_path(study_id)
summary_path = study_path / "3_insights" / "insights_summary.json"
if not summary_path.exists():
return {
"study_id": study_id,
"generated_at": None,
"insights": []
}
with open(summary_path) as f:
summary = json.load(f)
summary["study_id"] = study_id
return summary
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,9 +1,8 @@
import { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Eye,
RefreshCw,
Download,
Maximize2,
X,
Activity,
@@ -14,16 +13,41 @@ import {
AlertCircle,
CheckCircle,
Clock,
FileText
FileText,
Lightbulb,
Target,
Layers,
BookOpen,
ChevronRight,
ChevronDown,
Folder,
Play,
ExternalLink,
Zap,
List
} 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[];
}
@@ -38,13 +62,22 @@ interface GeneratedFile {
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, React.ElementType> = {
zernike_wfe: Waves,
zernike_opd_comparison: Waves,
msf_zernike: Waves,
stress_field: Activity,
modal: Box,
thermal: Thermometer,
@@ -53,26 +86,54 @@ const INSIGHT_ICONS: Record<string, React.ElementType> = {
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[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Active insight for display
// 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) {
@@ -80,47 +141,101 @@ export default function Insights() {
}
}, [selectedStudy, navigate, isInitialized]);
// Load available insights and generated files
const loadInsights = useCallback(async () => {
// Load iterations on mount (single fast API call + generated files for quick access)
const loadIterations = useCallback(async () => {
if (!selectedStudy) return;
setLoading(true);
setLoadingIterations(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 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`)
]);
// Load previously generated files
const genRes = await fetch(`/api/insights/studies/${selectedStudy.id}/insights/generated`);
const genData = await genRes.json();
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 insights:', err);
setError('Failed to load insights data');
console.error('Failed to load iterations:', err);
setError('Failed to load iterations');
} finally {
setLoading(false);
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(() => {
loadInsights();
}, [loadInsights]);
loadIterations();
}, [loadIterations]);
// Generate an insight
const handleGenerate = async (insightType: string) => {
if (!selectedStudy || generating) return;
// Load insights when moving to step 2
useEffect(() => {
if (step === 'select-insight' && availableInsights.length === 0) {
loadAvailableInsights();
}
}, [step, availableInsights.length, loadAvailableInsights]);
setGenerating(insightType);
// 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}/insights/generate/${insightType}`,
{ method: 'POST' }
`/api/insights/studies/${selectedStudy.id}/generate/${selectedInsightType}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
iteration: selectedIteration.id
})
}
);
if (!res.ok) {
@@ -129,59 +244,54 @@ export default function Insights() {
}
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 file list
loadInsights();
// Refresh generated files list
loadAvailableInsights();
} catch (err: any) {
setError(err.message || 'Failed to generate insight');
} finally {
setGenerating(null);
setGenerating(false);
}
};
// View existing insight
const handleViewExisting = async (file: GeneratedFile) => {
// 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');
};
setGenerating(file.insight_type);
// Format timestamp
const formatTime = (ts: number) => {
const date = new Date(ts * 1000);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
try {
const res = await fetch(
`/api/insights/studies/${selectedStudy.id}/insights/generate/${file.insight_type}`,
{ method: 'POST' }
);
// Group insights by category
const groupedInsights = useMemo(() => {
const groups: Record<string, InsightInfo[]> = {};
if (!res.ok) {
throw new Error('Failed to load insight');
availableInsights.forEach(insight => {
const cat = insight.category || 'other';
if (!groups[cat]) {
groups[cat] = [];
}
groups[cat].push(insight);
});
const result: InsightResult = await res.json();
setActiveInsight(result);
return groups;
}, [availableInsights]);
} 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';
};
const getIcon = (type: string) => 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">
@@ -194,23 +304,37 @@ export default function Insights() {
}
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 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 gap-2">
<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={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"
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 ${loading ? 'animate-spin' : ''}`} />
Refresh
<RefreshCw className="w-4 h-4" />
Reset
</button>
</div>
</header>
@@ -219,206 +343,409 @@ export default function Insights() {
{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">
<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>
)}
<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">
{/* 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" />
Loading...
Scanning for iterations...
</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>
) : 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="divide-y divide-dark-600">
{availableInsights.map((insight) => {
const Icon = getIcon(insight.type);
const colorClass = getColorClass(insight.type);
const isGenerating = generating === insight.type;
<div className="p-5 space-y-4">
{/* Best Design - Primary Option */}
{(() => {
const bestIter = iterations.find((i) => i.type === 'best');
if (!bestIter) return null;
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" />
<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="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 className="text-sm text-dark-400 mt-1">
{bestIter.label || bestIter.id} {formatTime(bestIter.modified)}
</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>
{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>
{/* 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);
{/* 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 (
<div
<button
key={file.filename}
className="p-3 hover:bg-dark-750 transition-colors flex items-center gap-3"
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" />
<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>
<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>
)}
{/* 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"
{/* 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"
>
{/* 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>
)}
Change
</button>
</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>
<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>
</Card>
) : 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>
</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"
>
&larr; 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_type.replace('_', ' ').toUpperCase()}
{activeInsight.insight_name || activeInsight.insight_type.replace(/_/g, ' ')}
</h2>
<button
onClick={() => setFullscreen(false)}