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:
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
← 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)}
|
||||
|
||||
Reference in New Issue
Block a user