feat: Add Analysis page, run comparison, notifications, and config editor

Dashboard enhancements:
- Add Analysis page with tabs: Overview, Parameters, Pareto, Correlations, Constraints, Surrogate, Runs
- Add PlotlyCorrelationHeatmap for parameter-objective correlation analysis
- Add PlotlyFeasibilityChart for constraint satisfaction visualization
- Add PlotlySurrogateQuality for FEA vs NN prediction comparison
- Add PlotlyRunComparison for comparing optimization runs within a study

Real-time improvements:
- Replace watchdog file-watching with SQLite database polling for better Windows reliability
- Add DatabasePoller class with 2-second polling interval
- Enhanced WebSocket messages: trial_completed, new_best, pareto_update, progress

Desktop notifications:
- Add useNotifications hook using Web Notifications API
- Add NotificationSettings toggle component
- Notify users when new best solutions are found

Config editor:
- Add PUT /studies/{study_id}/config endpoint with auto-backup
- Add ConfigEditor modal with tabs: General, Variables, Objectives, Settings, JSON
- Prevents editing while optimization is running

Enhanced Pareto visualization:
- Add dark mode styling with transparent backgrounds
- Add stats bar showing Pareto, FEA, NN, and infeasible counts
- Add Pareto front connecting line for 2D view
- Add table showing top 10 Pareto-optimal solutions

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Antoine
2025-12-05 19:57:20 -05:00
parent 5c660ff270
commit 5fb94fdf01
27 changed files with 5878 additions and 722 deletions

View File

@@ -5,7 +5,9 @@ import { ClaudeTerminalProvider } from './context/ClaudeTerminalContext';
import { MainLayout } from './components/layout/MainLayout';
import { GlobalClaudeTerminal } from './components/GlobalClaudeTerminal';
import Home from './pages/Home';
import Setup from './pages/Setup';
import Dashboard from './pages/Dashboard';
import Analysis from './pages/Analysis';
import Results from './pages/Results';
const queryClient = new QueryClient({
@@ -29,9 +31,10 @@ function App() {
{/* Study pages - with sidebar layout */}
<Route element={<MainLayout />}>
<Route path="setup" element={<Setup />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="analysis" element={<Analysis />} />
<Route path="results" element={<Results />} />
<Route path="analytics" element={<Dashboard />} />
</Route>
</Routes>

View File

@@ -52,6 +52,22 @@ export interface ProcessStatus {
nn_count?: number;
}
export interface ModelFile {
name: string;
path: string;
extension: string;
size_bytes: number;
size_display: string;
modified: string;
}
export interface ModelFilesResponse {
study_id: string;
model_dir: string;
files: ModelFile[];
count: number;
}
class ApiClient {
async getStudies(): Promise<StudyListResponse> {
const response = await fetch(`${API_BASE}/optimization/studies`);
@@ -193,6 +209,64 @@ class ApiClient {
}
return response.json();
}
// Model files
async getModelFiles(studyId: string): Promise<ModelFilesResponse> {
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/model-files`);
if (!response.ok) throw new Error('Failed to fetch model files');
return response.json();
}
async openFolder(studyId: string, folderType: 'model' | 'results' | 'setup' = 'model'): Promise<{ success: boolean; message: string; path: string }> {
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/open-folder?folder_type=${folderType}`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to open folder');
}
return response.json();
}
async getBestSolution(studyId: string): Promise<{
study_id: string;
best_trial: {
trial_number: number;
objective: number;
design_variables: Record<string, number>;
user_attrs?: Record<string, any>;
timestamp?: string;
} | null;
first_trial: {
trial_number: number;
objective: number;
design_variables: Record<string, number>;
} | null;
improvements: Record<string, {
initial: number;
final: number;
improvement_pct: number;
absolute_change: number;
}>;
total_trials: number;
}> {
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/best-solution`);
if (!response.ok) throw new Error('Failed to fetch best solution');
return response.json();
}
async exportData(studyId: string, format: 'csv' | 'json' | 'config'): Promise<{
filename?: string;
content: string;
content_type?: string;
study_id?: string;
total_trials?: number;
trials?: any[];
}> {
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/export/${format}`);
if (!response.ok) throw new Error(`Failed to export ${format}`);
return response.json();
}
}
export const apiClient = new ApiClient();

View File

@@ -10,7 +10,8 @@ import {
X,
RefreshCw,
AlertCircle,
FolderOpen
FolderOpen,
Plus
} from 'lucide-react';
import { useStudy } from '../context/StudyContext';
import { useClaudeTerminal } from '../context/ClaudeTerminalContext';
@@ -256,16 +257,29 @@ export const ClaudeTerminal: React.FC<ClaudeTerminalProps> = ({
// Set study context - sends context message to Claude silently
const setStudyContext = useCallback(() => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !selectedStudy?.id) return;
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
setSettingContext(true);
// Send context message with POS bootstrap instructions and study context
const contextMessage =
`You are helping with Atomizer optimization. ` +
`First read: .claude/skills/00_BOOTSTRAP.md for task routing. ` +
`Then follow the Protocol Execution Framework. ` +
`Study context: Working on "${selectedStudy.id}" at studies/${selectedStudy.id}/. ` +
`Use atomizer conda env. Acknowledge briefly.`;
let contextMessage: string;
if (selectedStudy?.id) {
// Existing study context
contextMessage =
`You are helping with Atomizer optimization. ` +
`First read: .claude/skills/00_BOOTSTRAP.md for task routing. ` +
`Then follow the Protocol Execution Framework. ` +
`Study context: Working on "${selectedStudy.id}" at studies/${selectedStudy.id}/. ` +
`Use atomizer conda env. Acknowledge briefly.`;
} else {
// No study selected - offer to create new study
contextMessage =
`You are helping with Atomizer optimization. ` +
`First read: .claude/skills/00_BOOTSTRAP.md for task routing. ` +
`No study is currently selected. ` +
`Read .claude/skills/guided-study-wizard.md and help the user create a new optimization study. ` +
`Use atomizer conda env. Start the guided wizard by asking what they want to optimize.`;
}
wsRef.current.send(JSON.stringify({ type: 'input', data: contextMessage + '\n' }));
// Mark as done after Claude has had time to process
@@ -325,33 +339,37 @@ export const ClaudeTerminal: React.FC<ClaudeTerminalProps> = ({
{isConnected ? 'Disconnect' : 'Connect'}
</button>
{/* Set Context button - always show, with different states */}
{/* Set Context button - works for both existing study and new study creation */}
<button
onClick={setStudyContext}
disabled={!selectedStudy?.id || !isConnected || settingContext || contextSet}
disabled={!isConnected || settingContext || contextSet}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-2 ${
contextSet
? 'bg-green-600/20 text-green-400'
: !selectedStudy?.id || !isConnected
: !isConnected
? 'bg-dark-600 text-dark-400'
: 'bg-primary-600/20 text-primary-400 hover:bg-primary-600/30'
: selectedStudy?.id
? 'bg-primary-600/20 text-primary-400 hover:bg-primary-600/30'
: 'bg-yellow-600/20 text-yellow-400 hover:bg-yellow-600/30'
} disabled:opacity-50 disabled:cursor-not-allowed`}
title={
!selectedStudy?.id
? 'No study selected - select a study from Home page'
: !isConnected
? 'Connect first to set study context'
: contextSet
? 'Context already set'
: `Set context to study: ${selectedStudy.id}`
!isConnected
? 'Connect first to set context'
: contextSet
? 'Context already set'
: selectedStudy?.id
? `Set context to study: ${selectedStudy.id}`
: 'Start guided study creation wizard'
}
>
{settingContext ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
) : selectedStudy?.id ? (
<FolderOpen className="w-3 h-3" />
) : (
<Plus className="w-3 h-3" />
)}
{contextSet ? 'Context Set' : selectedStudy?.id ? 'Set Context' : 'No Study'}
{contextSet ? 'Context Set' : selectedStudy?.id ? 'Set Context' : 'New Study'}
</button>
{onToggleExpand && (

View File

@@ -0,0 +1,458 @@
import { useState, useEffect, useCallback } from 'react';
import { Settings, Save, X, AlertTriangle, Check, RotateCcw } from 'lucide-react';
import { Card } from './common/Card';
interface DesignVariable {
name: string;
min: number;
max: number;
type?: string;
description?: string;
}
interface Objective {
name: string;
direction: 'minimize' | 'maximize';
description?: string;
unit?: string;
}
interface OptimizationConfig {
study_name?: string;
description?: string;
design_variables?: DesignVariable[];
objectives?: Objective[];
constraints?: any[];
optimization_settings?: {
n_trials?: number;
sampler?: string;
[key: string]: any;
};
[key: string]: any;
}
interface ConfigEditorProps {
studyId: string;
onClose: () => void;
onSaved?: () => void;
}
export function ConfigEditor({ studyId, onClose, onSaved }: ConfigEditorProps) {
const [config, setConfig] = useState<OptimizationConfig | null>(null);
const [originalConfig, setOriginalConfig] = useState<OptimizationConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [activeTab, setActiveTab] = useState<'general' | 'variables' | 'objectives' | 'settings' | 'json'>('general');
const [jsonText, setJsonText] = useState('');
const [jsonError, setJsonError] = useState<string | null>(null);
// Load config
useEffect(() => {
const loadConfig = async () => {
try {
setLoading(true);
setError(null);
// Check if optimization is running
const processRes = await fetch(`/api/optimization/studies/${studyId}/process`);
const processData = await processRes.json();
setIsRunning(processData.is_running);
// Load config
const configRes = await fetch(`/api/optimization/studies/${studyId}/config`);
if (!configRes.ok) {
throw new Error('Failed to load config');
}
const configData = await configRes.json();
setConfig(configData.config);
setOriginalConfig(JSON.parse(JSON.stringify(configData.config)));
setJsonText(JSON.stringify(configData.config, null, 2));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load config');
} finally {
setLoading(false);
}
};
loadConfig();
}, [studyId]);
// Handle JSON text changes
const handleJsonChange = useCallback((text: string) => {
setJsonText(text);
setJsonError(null);
try {
const parsed = JSON.parse(text);
setConfig(parsed);
} catch (err) {
setJsonError('Invalid JSON');
}
}, []);
// Save config
const handleSave = async () => {
if (!config || isRunning) return;
try {
setSaving(true);
setError(null);
setSuccess(null);
const res = await fetch(`/api/optimization/studies/${studyId}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config })
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || 'Failed to save config');
}
setSuccess('Configuration saved successfully');
setOriginalConfig(JSON.parse(JSON.stringify(config)));
onSaved?.();
// Clear success after 3 seconds
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save config');
} finally {
setSaving(false);
}
};
// Reset to original
const handleReset = () => {
if (originalConfig) {
setConfig(JSON.parse(JSON.stringify(originalConfig)));
setJsonText(JSON.stringify(originalConfig, null, 2));
setJsonError(null);
}
};
// Check if there are unsaved changes
const hasChanges = config && originalConfig
? JSON.stringify(config) !== JSON.stringify(originalConfig)
: false;
// Update a design variable
const updateDesignVariable = (index: number, field: keyof DesignVariable, value: any) => {
if (!config?.design_variables) return;
const newVars = [...config.design_variables];
newVars[index] = { ...newVars[index], [field]: value };
setConfig({ ...config, design_variables: newVars });
setJsonText(JSON.stringify({ ...config, design_variables: newVars }, null, 2));
};
// Update an objective
const updateObjective = (index: number, field: keyof Objective, value: any) => {
if (!config?.objectives) return;
const newObjs = [...config.objectives];
newObjs[index] = { ...newObjs[index], [field]: value };
setConfig({ ...config, objectives: newObjs });
setJsonText(JSON.stringify({ ...config, objectives: newObjs }, null, 2));
};
// Update optimization settings
const updateSettings = (field: string, value: any) => {
if (!config) return;
const newSettings = { ...config.optimization_settings, [field]: value };
setConfig({ ...config, optimization_settings: newSettings });
setJsonText(JSON.stringify({ ...config, optimization_settings: newSettings }, null, 2));
};
if (loading) {
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
<Card className="w-full max-w-4xl max-h-[90vh] p-6">
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full"></div>
</div>
</Card>
</div>
);
}
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-4xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-600">
<div className="flex items-center gap-3">
<Settings className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Edit Configuration</h2>
{hasChanges && (
<span className="px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded">
Unsaved changes
</span>
)}
</div>
<button
onClick={onClose}
className="p-2 text-dark-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Warning if running */}
{isRunning && (
<div className="m-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-red-400" />
<span className="text-red-400 text-sm">
Optimization is running. Stop it before editing configuration.
</span>
</div>
)}
{/* Tabs */}
<div className="flex border-b border-dark-600 px-4">
{(['general', 'variables', 'objectives', 'settings', 'json'] as const).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab
? 'text-primary-400 border-b-2 border-primary-400 -mb-[2px]'
: 'text-dark-400 hover:text-white'
}`}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
{success && (
<div className="mb-4 p-3 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400 text-sm flex items-center gap-2">
<Check className="w-4 h-4" />
{success}
</div>
)}
{config && (
<>
{/* General Tab */}
{activeTab === 'general' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
Study Name
</label>
<input
type="text"
value={config.study_name || ''}
onChange={(e) => {
setConfig({ ...config, study_name: e.target.value });
setJsonText(JSON.stringify({ ...config, study_name: e.target.value }, null, 2));
}}
disabled={isRunning}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
Description
</label>
<textarea
value={config.description || ''}
onChange={(e) => {
setConfig({ ...config, description: e.target.value });
setJsonText(JSON.stringify({ ...config, description: e.target.value }, null, 2));
}}
disabled={isRunning}
rows={3}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
/>
</div>
</div>
)}
{/* Design Variables Tab */}
{activeTab === 'variables' && (
<div className="space-y-4">
<p className="text-dark-400 text-sm mb-4">
Edit design variable bounds. These control the parameter search space.
</p>
{config.design_variables?.map((dv, index) => (
<div key={dv.name} className="p-4 bg-dark-750 rounded-lg">
<div className="font-medium text-white mb-3">{dv.name}</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-dark-400 mb-1">Min</label>
<input
type="number"
value={dv.min}
onChange={(e) => updateDesignVariable(index, 'min', parseFloat(e.target.value))}
disabled={isRunning}
step="any"
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
/>
</div>
<div>
<label className="block text-xs text-dark-400 mb-1">Max</label>
<input
type="number"
value={dv.max}
onChange={(e) => updateDesignVariable(index, 'max', parseFloat(e.target.value))}
disabled={isRunning}
step="any"
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
/>
</div>
</div>
</div>
))}
</div>
)}
{/* Objectives Tab */}
{activeTab === 'objectives' && (
<div className="space-y-4">
<p className="text-dark-400 text-sm mb-4">
Configure optimization objectives and their directions.
</p>
{config.objectives?.map((obj, index) => (
<div key={obj.name} className="p-4 bg-dark-750 rounded-lg">
<div className="font-medium text-white mb-3">{obj.name}</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-dark-400 mb-1">Direction</label>
<select
value={obj.direction}
onChange={(e) => updateObjective(index, 'direction', e.target.value)}
disabled={isRunning}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
>
<option value="minimize">Minimize</option>
<option value="maximize">Maximize</option>
</select>
</div>
<div>
<label className="block text-xs text-dark-400 mb-1">Unit</label>
<input
type="text"
value={obj.unit || ''}
onChange={(e) => updateObjective(index, 'unit', e.target.value)}
disabled={isRunning}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
/>
</div>
</div>
</div>
))}
</div>
)}
{/* Settings Tab */}
{activeTab === 'settings' && (
<div className="space-y-4">
<p className="text-dark-400 text-sm mb-4">
Optimization algorithm settings.
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
Number of Trials
</label>
<input
type="number"
value={config.optimization_settings?.n_trials || 100}
onChange={(e) => updateSettings('n_trials', parseInt(e.target.value))}
disabled={isRunning}
min={1}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
Sampler
</label>
<select
value={config.optimization_settings?.sampler || 'TPE'}
onChange={(e) => updateSettings('sampler', e.target.value)}
disabled={isRunning}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
>
<option value="TPE">TPE (Tree-structured Parzen Estimator)</option>
<option value="CMA-ES">CMA-ES (Evolution Strategy)</option>
<option value="NSGA-II">NSGA-II (Multi-objective)</option>
<option value="Random">Random</option>
<option value="QMC">QMC (Quasi-Monte Carlo)</option>
</select>
</div>
</div>
</div>
)}
{/* JSON Tab */}
{activeTab === 'json' && (
<div className="space-y-2">
<p className="text-dark-400 text-sm">
Edit the raw JSON configuration. Be careful with syntax.
</p>
{jsonError && (
<div className="p-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-xs">
{jsonError}
</div>
)}
<textarea
value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)}
disabled={isRunning}
spellCheck={false}
className="w-full h-96 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white font-mono text-sm disabled:opacity-50"
/>
</div>
)}
</>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-dark-600">
<button
onClick={handleReset}
disabled={!hasChanges || isRunning}
className="flex items-center gap-2 px-4 py-2 text-dark-400 hover:text-white disabled:opacity-50 transition-colors"
>
<RotateCcw className="w-4 h-4" />
Reset
</button>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 bg-dark-700 text-white rounded-lg hover:bg-dark-600 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving || !hasChanges || isRunning || !!jsonError}
className="flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 disabled:opacity-50 transition-colors"
>
<Save className="w-4 h-4" />
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</Card>
</div>
);
}
export default ConfigEditor;

View File

@@ -0,0 +1,184 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
interface MarkdownRendererProps {
content: string;
className?: string;
}
/**
* Shared markdown renderer with syntax highlighting, GFM, and LaTeX support.
* Used by both the Home page (README display) and Results page (reports).
*/
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className = '' }) => {
return (
<article className={`markdown-body max-w-none ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[[rehypeKatex, { strict: false, trust: true, output: 'html' }]]}
components={{
// Custom heading styles
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-white mb-6 pb-3 border-b border-dark-600">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-semibold text-white mt-10 mb-4 pb-2 border-b border-dark-700">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-semibold text-white mt-8 mb-3">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-medium text-white mt-6 mb-2">
{children}
</h4>
),
// Paragraphs
p: ({ children }) => (
<p className="text-dark-300 leading-relaxed mb-4">
{children}
</p>
),
// Strong/Bold
strong: ({ children }) => (
<strong className="text-white font-semibold">{children}</strong>
),
// Links
a: ({ href, children }) => (
<a
href={href}
className="text-primary-400 hover:text-primary-300 underline underline-offset-2"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
// Lists
ul: ({ children }) => (
<ul className="list-disc list-inside text-dark-300 mb-4 space-y-1.5 ml-2">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside text-dark-300 mb-4 space-y-1.5 ml-2">
{children}
</ol>
),
li: ({ children }) => (
<li className="text-dark-300 leading-relaxed">{children}</li>
),
// Code blocks with syntax highlighting
code: ({ inline, className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
if (!inline && language) {
return (
<div className="my-4 rounded-lg overflow-hidden border border-dark-600">
<div className="bg-dark-700 px-4 py-2 text-xs text-dark-400 font-mono border-b border-dark-600">
{language}
</div>
<SyntaxHighlighter
style={oneDark}
language={language}
PreTag="div"
customStyle={{
margin: 0,
padding: '1rem',
background: '#1a1d23',
fontSize: '0.875rem',
}}
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
);
}
if (!inline) {
return (
<pre className="my-4 p-4 bg-dark-700 rounded-lg border border-dark-600 overflow-x-auto">
<code className="text-primary-400 text-sm font-mono">{children}</code>
</pre>
);
}
return (
<code className="px-1.5 py-0.5 bg-dark-700 text-primary-400 rounded text-sm font-mono">
{children}
</code>
);
},
// Tables
table: ({ children }) => (
<div className="my-6 overflow-x-auto rounded-lg border border-dark-600">
<table className="w-full text-sm">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-dark-700 text-white">
{children}
</thead>
),
tbody: ({ children }) => (
<tbody className="divide-y divide-dark-600">
{children}
</tbody>
),
tr: ({ children }) => (
<tr className="hover:bg-dark-750 transition-colors">
{children}
</tr>
),
th: ({ children }) => (
<th className="px-4 py-3 text-left font-semibold text-white border-b border-dark-600">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-3 text-dark-300">
{children}
</td>
),
// Blockquotes
blockquote: ({ children }) => (
<blockquote className="my-4 pl-4 border-l-4 border-primary-500 bg-dark-750 py-3 pr-4 rounded-r-lg">
{children}
</blockquote>
),
// Horizontal rules
hr: () => (
<hr className="my-8 border-dark-600" />
),
// Images
img: ({ src, alt }) => (
<img
src={src}
alt={alt}
className="my-4 rounded-lg max-w-full h-auto border border-dark-600"
/>
),
}}
>
{content}
</ReactMarkdown>
</article>
);
};
export default MarkdownRenderer;

View File

@@ -0,0 +1,107 @@
import { Bell, BellOff, BellRing } from 'lucide-react';
import { useNotifications } from '../hooks/useNotifications';
interface NotificationSettingsProps {
compact?: boolean;
}
export function NotificationSettings({ compact = false }: NotificationSettingsProps) {
const { permission, requestPermission, isEnabled, setEnabled } = useNotifications();
const handleToggle = async () => {
if (permission === 'unsupported') {
return;
}
if (!isEnabled) {
// Enabling - request permission if needed
if (permission !== 'granted') {
const granted = await requestPermission();
if (granted) {
setEnabled(true);
}
} else {
setEnabled(true);
}
} else {
// Disabling
setEnabled(false);
}
};
if (permission === 'unsupported') {
return null;
}
const getIcon = () => {
if (!isEnabled) return <BellOff className="w-4 h-4" />;
if (permission === 'denied') return <BellOff className="w-4 h-4 text-red-400" />;
return <BellRing className="w-4 h-4" />;
};
const getStatus = () => {
if (permission === 'denied') return 'Blocked';
if (!isEnabled) return 'Off';
return 'On';
};
if (compact) {
return (
<button
onClick={handleToggle}
className={`flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors ${
isEnabled && permission === 'granted'
? 'bg-primary-500/20 text-primary-400 hover:bg-primary-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
}`}
title={`Desktop notifications: ${getStatus()}`}
>
{getIcon()}
<span className="hidden sm:inline">{getStatus()}</span>
</button>
);
}
return (
<div className="flex items-center justify-between p-3 bg-dark-750 rounded-lg">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${
isEnabled && permission === 'granted'
? 'bg-primary-500/20 text-primary-400'
: 'bg-dark-700 text-dark-400'
}`}>
<Bell className="w-5 h-5" />
</div>
<div>
<div className="text-sm font-medium text-white">Desktop Notifications</div>
<div className="text-xs text-dark-400">
{permission === 'denied'
? 'Blocked by browser - enable in browser settings'
: isEnabled
? 'Get notified when new best solutions are found'
: 'Enable to receive optimization updates'}
</div>
</div>
</div>
<button
onClick={handleToggle}
disabled={permission === 'denied'}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
isEnabled && permission === 'granted'
? 'bg-primary-500'
: permission === 'denied'
? 'bg-dark-600 cursor-not-allowed'
: 'bg-dark-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
isEnabled && permission === 'granted' ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
);
}
export default NotificationSettings;

View File

@@ -1,20 +1,25 @@
import { NavLink, useNavigate } from 'react-router-dom';
import {
Home,
Settings,
Activity,
FileText,
BarChart3,
TrendingUp,
ChevronLeft,
Play,
Pause,
CheckCircle,
Clock,
Zap
Zap,
Terminal
} from 'lucide-react';
import clsx from 'clsx';
import { useStudy } from '../../context/StudyContext';
import { useClaudeTerminal } from '../../context/ClaudeTerminalContext';
export const Sidebar = () => {
const { selectedStudy, clearStudy } = useStudy();
const { isConnected: claudeConnected, setIsOpen: setClaudeTerminalOpen } = useClaudeTerminal();
const navigate = useNavigate();
const handleBackToHome = () => {
@@ -26,8 +31,12 @@ export const Sidebar = () => {
switch (status) {
case 'running':
return <Play className="w-3 h-3 text-green-400" />;
case 'paused':
return <Pause className="w-3 h-3 text-orange-400" />;
case 'completed':
return <CheckCircle className="w-3 h-3 text-blue-400" />;
case 'not_started':
return <Clock className="w-3 h-3 text-dark-400" />;
default:
return <Clock className="w-3 h-3 text-dark-400" />;
}
@@ -37,8 +46,12 @@ export const Sidebar = () => {
switch (status) {
case 'running':
return 'text-green-400';
case 'paused':
return 'text-orange-400';
case 'completed':
return 'text-blue-400';
case 'not_started':
return 'text-dark-400';
default:
return 'text-dark-400';
}
@@ -47,9 +60,10 @@ export const Sidebar = () => {
// Navigation items depend on whether a study is selected
const navItems = selectedStudy
? [
{ to: '/setup', icon: Settings, label: 'Setup' },
{ to: '/dashboard', icon: Activity, label: 'Live Tracker' },
{ to: '/results', icon: FileText, label: 'Reports' },
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
{ to: '/analysis', icon: TrendingUp, label: 'Analysis' },
{ to: '/results', icon: FileText, label: 'Results' },
]
: [
{ to: '/', icon: Home, label: 'Select Study' },
@@ -133,6 +147,23 @@ export const Sidebar = () => {
Optimization Running
</div>
)}
{selectedStudy && selectedStudy.status === 'paused' && (
<div className="flex items-center gap-2 text-sm text-orange-400 mt-1">
<div className="w-2 h-2 bg-orange-500 rounded-full" />
Optimization Paused
</div>
)}
{/* Claude Terminal Status */}
<button
onClick={() => setClaudeTerminalOpen(true)}
className={clsx(
'flex items-center gap-2 text-sm mt-1 w-full text-left hover:opacity-80 transition-opacity',
claudeConnected ? 'text-green-400' : 'text-dark-400'
)}
>
<Terminal className="w-3 h-3" />
{claudeConnected ? 'Claude Connected' : 'Claude Disconnected'}
</button>
</div>
</div>
</aside>

View File

@@ -0,0 +1,161 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
interface TrialData {
trial_number: number;
values: number[];
params: Record<string, number>;
}
interface PlotlyCorrelationHeatmapProps {
trials: TrialData[];
objectiveName?: string;
height?: number;
}
// Calculate Pearson correlation coefficient
function pearsonCorrelation(x: number[], y: number[]): number {
const n = x.length;
if (n === 0 || n !== y.length) return 0;
const meanX = x.reduce((a, b) => a + b, 0) / n;
const meanY = y.reduce((a, b) => a + b, 0) / n;
let numerator = 0;
let denomX = 0;
let denomY = 0;
for (let i = 0; i < n; i++) {
const dx = x[i] - meanX;
const dy = y[i] - meanY;
numerator += dx * dy;
denomX += dx * dx;
denomY += dy * dy;
}
const denominator = Math.sqrt(denomX) * Math.sqrt(denomY);
return denominator === 0 ? 0 : numerator / denominator;
}
export function PlotlyCorrelationHeatmap({
trials,
objectiveName = 'Objective',
height = 500
}: PlotlyCorrelationHeatmapProps) {
const { matrix, labels, annotations } = useMemo(() => {
if (trials.length < 3) {
return { matrix: [], labels: [], annotations: [] };
}
// Get parameter names
const paramNames = Object.keys(trials[0].params);
const allLabels = [...paramNames, objectiveName];
// Extract data columns
const columns: Record<string, number[]> = {};
paramNames.forEach(name => {
columns[name] = trials.map(t => t.params[name]).filter(v => v !== undefined && !isNaN(v));
});
columns[objectiveName] = trials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
// Calculate correlation matrix
const n = allLabels.length;
const correlationMatrix: number[][] = [];
const annotationData: any[] = [];
for (let i = 0; i < n; i++) {
const row: number[] = [];
for (let j = 0; j < n; j++) {
const col1 = columns[allLabels[i]];
const col2 = columns[allLabels[j]];
// Ensure same length
const minLen = Math.min(col1.length, col2.length);
const corr = pearsonCorrelation(col1.slice(0, minLen), col2.slice(0, minLen));
row.push(corr);
// Add annotation
annotationData.push({
x: allLabels[j],
y: allLabels[i],
text: corr.toFixed(2),
showarrow: false,
font: {
color: Math.abs(corr) > 0.5 ? '#fff' : '#888',
size: 11
}
});
}
correlationMatrix.push(row);
}
return {
matrix: correlationMatrix,
labels: allLabels,
annotations: annotationData
};
}, [trials, objectiveName]);
if (trials.length < 3) {
return (
<div className="h-64 flex items-center justify-center text-dark-400">
<p>Need at least 3 trials to compute correlations</p>
</div>
);
}
return (
<Plot
data={[
{
z: matrix,
x: labels,
y: labels,
type: 'heatmap',
colorscale: [
[0, '#ef4444'], // -1: strong negative (red)
[0.25, '#f87171'], // -0.5: moderate negative
[0.5, '#1a1b26'], // 0: no correlation (dark)
[0.75, '#60a5fa'], // 0.5: moderate positive
[1, '#3b82f6'] // 1: strong positive (blue)
],
zmin: -1,
zmax: 1,
showscale: true,
colorbar: {
title: { text: 'Correlation', font: { color: '#888' } },
tickfont: { color: '#888' },
len: 0.8
},
hovertemplate: '%{y} vs %{x}<br>Correlation: %{z:.3f}<extra></extra>'
}
]}
layout={{
title: {
text: 'Parameter-Objective Correlation Matrix',
font: { color: '#fff', size: 14 }
},
height,
margin: { l: 120, r: 60, t: 60, b: 120 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
tickangle: 45,
tickfont: { color: '#888', size: 10 },
gridcolor: 'rgba(255,255,255,0.05)'
},
yaxis: {
tickfont: { color: '#888', size: 10 },
gridcolor: 'rgba(255,255,255,0.05)'
},
annotations: annotations
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
);
}

View File

@@ -0,0 +1,120 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
interface TrialData {
trial_number: number;
values: number[];
constraint_satisfied?: boolean;
}
interface PlotlyFeasibilityChartProps {
trials: TrialData[];
height?: number;
}
export function PlotlyFeasibilityChart({
trials,
height = 350
}: PlotlyFeasibilityChartProps) {
const { trialNumbers, cumulativeFeasibility, windowedFeasibility } = useMemo(() => {
if (trials.length === 0) {
return { trialNumbers: [], cumulativeFeasibility: [], windowedFeasibility: [] };
}
// Sort trials by number
const sorted = [...trials].sort((a, b) => a.trial_number - b.trial_number);
const numbers: number[] = [];
const cumulative: number[] = [];
const windowed: number[] = [];
let feasibleCount = 0;
const windowSize = Math.min(20, Math.floor(sorted.length / 5) || 1);
sorted.forEach((trial, idx) => {
numbers.push(trial.trial_number);
// Cumulative feasibility
if (trial.constraint_satisfied !== false) {
feasibleCount++;
}
cumulative.push((feasibleCount / (idx + 1)) * 100);
// Windowed (rolling) feasibility
const windowStart = Math.max(0, idx - windowSize + 1);
const windowTrials = sorted.slice(windowStart, idx + 1);
const windowFeasible = windowTrials.filter(t => t.constraint_satisfied !== false).length;
windowed.push((windowFeasible / windowTrials.length) * 100);
});
return { trialNumbers: numbers, cumulativeFeasibility: cumulative, windowedFeasibility: windowed };
}, [trials]);
if (trials.length === 0) {
return (
<div className="h-64 flex items-center justify-center text-dark-400">
<p>No trials to display</p>
</div>
);
}
return (
<Plot
data={[
{
x: trialNumbers,
y: cumulativeFeasibility,
type: 'scatter',
mode: 'lines',
name: 'Cumulative Feasibility',
line: { color: '#22c55e', width: 2 },
hovertemplate: 'Trial %{x}<br>Cumulative: %{y:.1f}%<extra></extra>'
},
{
x: trialNumbers,
y: windowedFeasibility,
type: 'scatter',
mode: 'lines',
name: 'Rolling (20-trial)',
line: { color: '#60a5fa', width: 2, dash: 'dot' },
hovertemplate: 'Trial %{x}<br>Rolling: %{y:.1f}%<extra></extra>'
}
]}
layout={{
height,
margin: { l: 60, r: 30, t: 30, b: 50 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: { text: 'Trial Number', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.05)',
zeroline: false
},
yaxis: {
title: { text: 'Feasibility Rate (%)', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.1)',
zeroline: false,
range: [0, 105]
},
legend: {
font: { color: '#888' },
bgcolor: 'rgba(0,0,0,0.5)',
x: 0.02,
y: 0.98,
xanchor: 'left',
yanchor: 'top'
},
showlegend: true,
hovermode: 'x unified'
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
);
}

View File

@@ -5,8 +5,10 @@
* - 2D scatter with Pareto front highlighted
* - 3D scatter for 3-objective problems
* - Hover tooltips with trial details
* - Click to select trials
* - Pareto front connection line
* - FEA vs NN differentiation
* - Constraint satisfaction highlighting
* - Dark mode styling
* - Zoom, pan, and export
*/
@@ -19,6 +21,7 @@ interface Trial {
params: Record<string, number>;
user_attrs?: Record<string, any>;
source?: 'FEA' | 'NN' | 'V10_FEA';
constraint_satisfied?: boolean;
}
interface Objective {
@@ -32,28 +35,37 @@ interface PlotlyParetoPlotProps {
paretoFront: Trial[];
objectives: Objective[];
height?: number;
showParetoLine?: boolean;
showInfeasible?: boolean;
}
export function PlotlyParetoPlot({
trials,
paretoFront,
objectives,
height = 500
height = 500,
showParetoLine = true,
showInfeasible = true
}: PlotlyParetoPlotProps) {
const [viewMode, setViewMode] = useState<'2d' | '3d'>(objectives.length >= 3 ? '3d' : '2d');
const [selectedObjectives, setSelectedObjectives] = useState<[number, number, number]>([0, 1, 2]);
const paretoSet = useMemo(() => new Set(paretoFront.map(t => t.trial_number)), [paretoFront]);
// Separate trials by source and Pareto status
const { feaTrials, nnTrials, paretoTrials } = useMemo(() => {
// Separate trials by source, Pareto status, and constraint satisfaction
const { feaTrials, nnTrials, paretoTrials, infeasibleTrials, stats } = useMemo(() => {
const fea: Trial[] = [];
const nn: Trial[] = [];
const pareto: Trial[] = [];
const infeasible: Trial[] = [];
trials.forEach(t => {
const source = t.source || t.user_attrs?.source || 'FEA';
if (paretoSet.has(t.trial_number)) {
const isFeasible = t.constraint_satisfied !== false && t.user_attrs?.constraint_satisfied !== false;
if (!isFeasible && showInfeasible) {
infeasible.push(t);
} else if (paretoSet.has(t.trial_number)) {
pareto.push(t);
} else if (source === 'NN') {
nn.push(t);
@@ -62,8 +74,18 @@ export function PlotlyParetoPlot({
}
});
return { feaTrials: fea, nnTrials: nn, paretoTrials: pareto };
}, [trials, paretoSet]);
// Calculate statistics
const stats = {
totalTrials: trials.length,
paretoCount: pareto.length,
feaCount: fea.length + pareto.filter(t => (t.source || 'FEA') !== 'NN').length,
nnCount: nn.length + pareto.filter(t => t.source === 'NN').length,
infeasibleCount: infeasible.length,
hypervolume: 0 // Could calculate if needed
};
return { feaTrials: fea, nnTrials: nn, paretoTrials: pareto, infeasibleTrials: infeasible, stats };
}, [trials, paretoSet, showInfeasible]);
// Helper to get objective value
const getObjValue = (trial: Trial, idx: number): number => {
@@ -135,80 +157,129 @@ export function PlotlyParetoPlot({
}
};
// Sort Pareto trials by first objective for line connection
const sortedParetoTrials = useMemo(() => {
const [i] = selectedObjectives;
return [...paretoTrials].sort((a, b) => getObjValue(a, i) - getObjValue(b, i));
}, [paretoTrials, selectedObjectives]);
// Create Pareto front line trace (2D only)
const createParetoLine = () => {
if (!showParetoLine || viewMode === '3d' || sortedParetoTrials.length < 2) return null;
const [i, j] = selectedObjectives;
return {
type: 'scatter' as const,
mode: 'lines' as const,
name: 'Pareto Front',
x: sortedParetoTrials.map(t => getObjValue(t, i)),
y: sortedParetoTrials.map(t => getObjValue(t, j)),
line: {
color: '#10B981',
width: 2,
dash: 'dot'
},
hoverinfo: 'skip' as const,
showlegend: false
};
};
const traces = [
// FEA trials (background, less prominent)
createTrace(feaTrials, `FEA (${feaTrials.length})`, '#93C5FD', 'circle', 8, 0.6),
// NN trials (background, less prominent)
createTrace(nnTrials, `NN (${nnTrials.length})`, '#FDBA74', 'cross', 8, 0.5),
// Pareto front (highlighted)
createTrace(paretoTrials, `Pareto (${paretoTrials.length})`, '#10B981', 'diamond', 12, 1.0)
].filter(trace => (trace.x as number[]).length > 0);
// Infeasible trials (background, red X)
...(showInfeasible && infeasibleTrials.length > 0 ? [
createTrace(infeasibleTrials, `Infeasible (${infeasibleTrials.length})`, '#EF4444', 'x', 7, 0.4)
] : []),
// FEA trials (blue circles)
createTrace(feaTrials, `FEA (${feaTrials.length})`, '#3B82F6', 'circle', 8, 0.6),
// NN trials (purple diamonds)
createTrace(nnTrials, `NN (${nnTrials.length})`, '#A855F7', 'diamond', 8, 0.5),
// Pareto front line (2D only)
createParetoLine(),
// Pareto front points (highlighted)
createTrace(sortedParetoTrials, `Pareto (${sortedParetoTrials.length})`, '#10B981', 'star', 14, 1.0)
].filter(trace => trace && (trace.x as number[]).length > 0);
const [i, j, k] = selectedObjectives;
// Dark mode color scheme
const colors = {
text: '#E5E7EB',
textMuted: '#9CA3AF',
grid: 'rgba(255,255,255,0.1)',
zeroline: 'rgba(255,255,255,0.2)',
legendBg: 'rgba(30,30,30,0.9)',
legendBorder: 'rgba(255,255,255,0.1)'
};
const layout: any = viewMode === '3d' && objectives.length >= 3
? {
height,
margin: { l: 50, r: 50, t: 30, b: 50 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
scene: {
xaxis: {
title: objectives[i]?.name || 'Objective 1',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
title: { text: objectives[i]?.name || 'Objective 1', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
yaxis: {
title: objectives[j]?.name || 'Objective 2',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
title: { text: objectives[j]?.name || 'Objective 2', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
zaxis: {
title: objectives[k]?.name || 'Objective 3',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
title: { text: objectives[k]?.name || 'Objective 3', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
bgcolor: 'rgba(0,0,0,0)'
bgcolor: 'transparent'
},
legend: {
x: 1,
y: 1,
bgcolor: 'rgba(255,255,255,0.8)',
bordercolor: '#E5E7EB',
font: { color: colors.text },
bgcolor: colors.legendBg,
bordercolor: colors.legendBorder,
borderwidth: 1
},
font: { family: 'Inter, system-ui, sans-serif' }
font: { family: 'Inter, system-ui, sans-serif', color: colors.text }
}
: {
height,
margin: { l: 60, r: 30, t: 30, b: 60 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: objectives[i]?.name || 'Objective 1',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
title: { text: objectives[i]?.name || 'Objective 1', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
yaxis: {
title: objectives[j]?.name || 'Objective 2',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
title: { text: objectives[j]?.name || 'Objective 2', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
legend: {
x: 1,
y: 1,
xanchor: 'right',
bgcolor: 'rgba(255,255,255,0.8)',
bordercolor: '#E5E7EB',
font: { color: colors.text },
bgcolor: colors.legendBg,
bordercolor: colors.legendBorder,
borderwidth: 1
},
font: { family: 'Inter, system-ui, sans-serif' },
font: { family: 'Inter, system-ui, sans-serif', color: colors.text },
hovermode: 'closest' as const
};
if (!trials.length) {
return (
<div className="flex items-center justify-center h-64 text-gray-500">
<div className="flex items-center justify-center h-64 text-dark-400">
No trial data available
</div>
);
@@ -216,20 +287,54 @@ export function PlotlyParetoPlot({
return (
<div className="w-full">
{/* Stats Bar */}
<div className="flex gap-4 mb-4 text-sm">
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-green-500 rounded-full" />
<span className="text-dark-300">Pareto:</span>
<span className="text-green-400 font-medium">{stats.paretoCount}</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-blue-500 rounded-full" />
<span className="text-dark-300">FEA:</span>
<span className="text-blue-400 font-medium">{stats.feaCount}</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-purple-500 rounded-full" />
<span className="text-dark-300">NN:</span>
<span className="text-purple-400 font-medium">{stats.nnCount}</span>
</div>
{stats.infeasibleCount > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-red-500 rounded-full" />
<span className="text-dark-300">Infeasible:</span>
<span className="text-red-400 font-medium">{stats.infeasibleCount}</span>
</div>
)}
</div>
{/* Controls */}
<div className="flex gap-4 items-center justify-between mb-3">
<div className="flex gap-2 items-center">
{objectives.length >= 3 && (
<div className="flex rounded-lg overflow-hidden border border-gray-300">
<div className="flex rounded-lg overflow-hidden border border-dark-600">
<button
onClick={() => setViewMode('2d')}
className={`px-3 py-1 text-sm ${viewMode === '2d' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
viewMode === '2d'
? 'bg-primary-600 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-white'
}`}
>
2D
</button>
<button
onClick={() => setViewMode('3d')}
className={`px-3 py-1 text-sm ${viewMode === '3d' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
viewMode === '3d'
? 'bg-primary-600 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-white'
}`}
>
3D
</button>
@@ -239,22 +344,22 @@ export function PlotlyParetoPlot({
{/* Objective selectors */}
<div className="flex gap-2 items-center text-sm">
<label className="text-gray-600">X:</label>
<label className="text-dark-400">X:</label>
<select
value={selectedObjectives[0]}
onChange={(e) => setSelectedObjectives([parseInt(e.target.value), selectedObjectives[1], selectedObjectives[2]])}
className="px-2 py-1 border border-gray-300 rounded text-sm"
className="px-2 py-1.5 bg-dark-700 border border-dark-600 rounded text-white text-sm"
>
{objectives.map((obj, idx) => (
<option key={idx} value={idx}>{obj.name}</option>
))}
</select>
<label className="text-gray-600 ml-2">Y:</label>
<label className="text-dark-400 ml-2">Y:</label>
<select
value={selectedObjectives[1]}
onChange={(e) => setSelectedObjectives([selectedObjectives[0], parseInt(e.target.value), selectedObjectives[2]])}
className="px-2 py-1 border border-gray-300 rounded text-sm"
className="px-2 py-1.5 bg-dark-700 border border-dark-600 rounded text-white text-sm"
>
{objectives.map((obj, idx) => (
<option key={idx} value={idx}>{obj.name}</option>
@@ -263,11 +368,11 @@ export function PlotlyParetoPlot({
{viewMode === '3d' && objectives.length >= 3 && (
<>
<label className="text-gray-600 ml-2">Z:</label>
<label className="text-dark-400 ml-2">Z:</label>
<select
value={selectedObjectives[2]}
onChange={(e) => setSelectedObjectives([selectedObjectives[0], selectedObjectives[1], parseInt(e.target.value)])}
className="px-2 py-1 border border-gray-300 rounded text-sm"
className="px-2 py-1.5 bg-dark-700 border border-dark-600 rounded text-white text-sm"
>
{objectives.map((obj, idx) => (
<option key={idx} value={idx}>{obj.name}</option>
@@ -284,7 +389,7 @@ export function PlotlyParetoPlot({
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['lasso2d'],
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
toImageButtonOptions: {
format: 'png',
filename: 'pareto_front',
@@ -295,6 +400,49 @@ export function PlotlyParetoPlot({
}}
style={{ width: '100%' }}
/>
{/* Pareto Front Table for 2D view */}
{viewMode === '2d' && sortedParetoTrials.length > 0 && (
<div className="mt-4 max-h-48 overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-dark-800">
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trial</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">{objectives[i]?.name || 'Obj 1'}</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">{objectives[j]?.name || 'Obj 2'}</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
</tr>
</thead>
<tbody>
{sortedParetoTrials.slice(0, 10).map(trial => (
<tr key={trial.trial_number} className="border-b border-dark-700 hover:bg-dark-750">
<td className="py-2 px-3 font-mono text-white">#{trial.trial_number}</td>
<td className="py-2 px-3 font-mono text-green-400">
{getObjValue(trial, i).toExponential(4)}
</td>
<td className="py-2 px-3 font-mono text-green-400">
{getObjValue(trial, j).toExponential(4)}
</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
(trial.source || trial.user_attrs?.source) === 'NN'
? 'bg-purple-500/20 text-purple-400'
: 'bg-blue-500/20 text-blue-400'
}`}>
{trial.source || trial.user_attrs?.source || 'FEA'}
</span>
</td>
</tr>
))}
</tbody>
</table>
{sortedParetoTrials.length > 10 && (
<div className="text-center py-2 text-dark-500 text-xs">
Showing 10 of {sortedParetoTrials.length} Pareto-optimal solutions
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,247 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
interface Run {
run_id: number;
name: string;
source: 'FEA' | 'NN';
trial_count: number;
best_value: number | null;
avg_value: number | null;
first_trial: string | null;
last_trial: string | null;
}
interface PlotlyRunComparisonProps {
runs: Run[];
height?: number;
}
export function PlotlyRunComparison({ runs, height = 400 }: PlotlyRunComparisonProps) {
const chartData = useMemo(() => {
if (runs.length === 0) return null;
// Separate FEA and NN runs
const feaRuns = runs.filter(r => r.source === 'FEA');
const nnRuns = runs.filter(r => r.source === 'NN');
// Create bar chart for trial counts
const trialCountData = {
x: runs.map(r => r.name),
y: runs.map(r => r.trial_count),
type: 'bar' as const,
name: 'Trial Count',
marker: {
color: runs.map(r => r.source === 'NN' ? 'rgba(147, 51, 234, 0.8)' : 'rgba(59, 130, 246, 0.8)'),
line: { color: runs.map(r => r.source === 'NN' ? 'rgb(147, 51, 234)' : 'rgb(59, 130, 246)'), width: 1 }
},
hovertemplate: '<b>%{x}</b><br>Trials: %{y}<extra></extra>'
};
// Create line chart for best values
const bestValueData = {
x: runs.map(r => r.name),
y: runs.map(r => r.best_value),
type: 'scatter' as const,
mode: 'lines+markers' as const,
name: 'Best Value',
yaxis: 'y2',
line: { color: 'rgba(16, 185, 129, 1)', width: 2 },
marker: { size: 8, color: 'rgba(16, 185, 129, 1)' },
hovertemplate: '<b>%{x}</b><br>Best: %{y:.4e}<extra></extra>'
};
return { trialCountData, bestValueData, feaRuns, nnRuns };
}, [runs]);
// Calculate statistics
const stats = useMemo(() => {
if (runs.length === 0) return null;
const totalTrials = runs.reduce((sum, r) => sum + r.trial_count, 0);
const feaTrials = runs.filter(r => r.source === 'FEA').reduce((sum, r) => sum + r.trial_count, 0);
const nnTrials = runs.filter(r => r.source === 'NN').reduce((sum, r) => sum + r.trial_count, 0);
const bestValues = runs.map(r => r.best_value).filter((v): v is number => v !== null);
const overallBest = bestValues.length > 0 ? Math.min(...bestValues) : null;
// Calculate improvement from first FEA run to overall best
const feaRuns = runs.filter(r => r.source === 'FEA');
const firstFEA = feaRuns.length > 0 ? feaRuns[0].best_value : null;
const improvement = firstFEA && overallBest ? ((firstFEA - overallBest) / Math.abs(firstFEA)) * 100 : null;
return {
totalTrials,
feaTrials,
nnTrials,
overallBest,
improvement,
totalRuns: runs.length,
feaRuns: runs.filter(r => r.source === 'FEA').length,
nnRuns: runs.filter(r => r.source === 'NN').length
};
}, [runs]);
if (!chartData || !stats) {
return (
<div className="flex items-center justify-center h-64 text-dark-400">
No run data available
</div>
);
}
return (
<div className="space-y-4">
{/* Stats Summary */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Total Runs</div>
<div className="text-xl font-bold text-white">{stats.totalRuns}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Total Trials</div>
<div className="text-xl font-bold text-white">{stats.totalTrials}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">FEA Trials</div>
<div className="text-xl font-bold text-blue-400">{stats.feaTrials}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">NN Trials</div>
<div className="text-xl font-bold text-purple-400">{stats.nnTrials}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Best Value</div>
<div className="text-xl font-bold text-green-400">
{stats.overallBest !== null ? stats.overallBest.toExponential(3) : 'N/A'}
</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Improvement</div>
<div className="text-xl font-bold text-primary-400 flex items-center gap-1">
{stats.improvement !== null ? (
<>
{stats.improvement > 0 ? <TrendingDown className="w-4 h-4" /> :
stats.improvement < 0 ? <TrendingUp className="w-4 h-4" /> :
<Minus className="w-4 h-4" />}
{Math.abs(stats.improvement).toFixed(1)}%
</>
) : 'N/A'}
</div>
</div>
</div>
{/* Chart */}
<Plot
data={[chartData.trialCountData, chartData.bestValueData]}
layout={{
height,
margin: { l: 60, r: 60, t: 40, b: 100 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
font: { color: '#9ca3af', size: 11 },
showlegend: true,
legend: {
orientation: 'h',
y: 1.12,
x: 0.5,
xanchor: 'center',
bgcolor: 'transparent'
},
xaxis: {
tickangle: -45,
gridcolor: 'rgba(75, 85, 99, 0.3)',
linecolor: 'rgba(75, 85, 99, 0.5)',
tickfont: { size: 10 }
},
yaxis: {
title: { text: 'Trial Count' },
gridcolor: 'rgba(75, 85, 99, 0.3)',
linecolor: 'rgba(75, 85, 99, 0.5)',
zeroline: false
},
yaxis2: {
title: { text: 'Best Value' },
overlaying: 'y',
side: 'right',
gridcolor: 'rgba(75, 85, 99, 0.1)',
linecolor: 'rgba(75, 85, 99, 0.5)',
zeroline: false,
tickformat: '.2e'
},
bargap: 0.3,
hovermode: 'x unified'
}}
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['select2d', 'lasso2d', 'autoScale2d']
}}
className="w-full"
useResizeHandler
style={{ width: '100%' }}
/>
{/* Runs Table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Run Name</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
<th className="text-right py-2 px-3 text-dark-400 font-medium">Trials</th>
<th className="text-right py-2 px-3 text-dark-400 font-medium">Best Value</th>
<th className="text-right py-2 px-3 text-dark-400 font-medium">Avg Value</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Duration</th>
</tr>
</thead>
<tbody>
{runs.map((run) => {
// Calculate duration if times available
let duration = '-';
if (run.first_trial && run.last_trial) {
const start = new Date(run.first_trial);
const end = new Date(run.last_trial);
const diffMs = end.getTime() - start.getTime();
const diffMins = Math.round(diffMs / 60000);
if (diffMins < 60) {
duration = `${diffMins}m`;
} else {
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
duration = `${hours}h ${mins}m`;
}
}
return (
<tr key={run.run_id} className="border-b border-dark-700 hover:bg-dark-750">
<td className="py-2 px-3 font-mono text-white">{run.name}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
run.source === 'NN'
? 'bg-purple-500/20 text-purple-400'
: 'bg-blue-500/20 text-blue-400'
}`}>
{run.source}
</span>
</td>
<td className="py-2 px-3 text-right font-mono text-white">{run.trial_count}</td>
<td className="py-2 px-3 text-right font-mono text-green-400">
{run.best_value !== null ? run.best_value.toExponential(4) : '-'}
</td>
<td className="py-2 px-3 text-right font-mono text-dark-300">
{run.avg_value !== null ? run.avg_value.toExponential(4) : '-'}
</td>
<td className="py-2 px-3 text-dark-400">{duration}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
export default PlotlyRunComparison;

View File

@@ -0,0 +1,202 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
interface TrialData {
trial_number: number;
values: number[];
source?: 'FEA' | 'NN' | 'V10_FEA';
user_attrs?: Record<string, any>;
}
interface PlotlySurrogateQualityProps {
trials: TrialData[];
height?: number;
}
export function PlotlySurrogateQuality({
trials,
height = 400
}: PlotlySurrogateQualityProps) {
const { feaTrials, nnTrials, timeline } = useMemo(() => {
const fea = trials.filter(t => t.source === 'FEA' || t.source === 'V10_FEA');
const nn = trials.filter(t => t.source === 'NN');
// Sort by trial number for timeline
const sorted = [...trials].sort((a, b) => a.trial_number - b.trial_number);
// Calculate source distribution over time
const timeline: { trial: number; feaCount: number; nnCount: number }[] = [];
let feaCount = 0;
let nnCount = 0;
sorted.forEach(t => {
if (t.source === 'NN') nnCount++;
else feaCount++;
timeline.push({
trial: t.trial_number,
feaCount,
nnCount
});
});
return {
feaTrials: fea,
nnTrials: nn,
timeline
};
}, [trials]);
if (nnTrials.length === 0) {
return (
<div className="h-64 flex items-center justify-center text-dark-400">
<p>No neural network evaluations in this study</p>
</div>
);
}
// Objective distribution by source
const feaObjectives = feaTrials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
const nnObjectives = nnTrials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
return (
<div className="space-y-6">
{/* Source Distribution Over Time */}
<Plot
data={[
{
x: timeline.map(t => t.trial),
y: timeline.map(t => t.feaCount),
type: 'scatter',
mode: 'lines',
name: 'FEA Cumulative',
line: { color: '#3b82f6', width: 2 },
fill: 'tozeroy',
fillcolor: 'rgba(59, 130, 246, 0.2)'
},
{
x: timeline.map(t => t.trial),
y: timeline.map(t => t.nnCount),
type: 'scatter',
mode: 'lines',
name: 'NN Cumulative',
line: { color: '#a855f7', width: 2 },
fill: 'tozeroy',
fillcolor: 'rgba(168, 85, 247, 0.2)'
}
]}
layout={{
title: {
text: 'Evaluation Source Over Time',
font: { color: '#fff', size: 14 }
},
height: height * 0.6,
margin: { l: 60, r: 30, t: 50, b: 50 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: { text: 'Trial Number', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.05)'
},
yaxis: {
title: { text: 'Cumulative Count', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.1)'
},
legend: {
font: { color: '#888' },
bgcolor: 'rgba(0,0,0,0.5)',
orientation: 'h',
y: 1.1
},
showlegend: true
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
{/* Objective Distribution by Source */}
<Plot
data={[
{
x: feaObjectives,
type: 'histogram',
name: 'FEA',
marker: { color: 'rgba(59, 130, 246, 0.7)' },
opacity: 0.8
} as any,
{
x: nnObjectives,
type: 'histogram',
name: 'NN',
marker: { color: 'rgba(168, 85, 247, 0.7)' },
opacity: 0.8
} as any
]}
layout={{
title: {
text: 'Objective Distribution by Source',
font: { color: '#fff', size: 14 }
},
height: height * 0.5,
margin: { l: 60, r: 30, t: 50, b: 50 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: { text: 'Objective Value', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.05)'
},
yaxis: {
title: { text: 'Count', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.1)'
},
barmode: 'overlay',
legend: {
font: { color: '#888' },
bgcolor: 'rgba(0,0,0,0.5)',
orientation: 'h',
y: 1.1
},
showlegend: true
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
{/* FEA vs NN Best Values Comparison */}
{feaObjectives.length > 0 && nnObjectives.length > 0 && (
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-2">FEA Best</div>
<div className="text-xl font-mono text-blue-400">
{Math.min(...feaObjectives).toExponential(4)}
</div>
<div className="text-xs text-dark-500 mt-1">
from {feaObjectives.length} evaluations
</div>
</div>
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-2">NN Best</div>
<div className="text-xl font-mono text-purple-400">
{Math.min(...nnObjectives).toExponential(4)}
</div>
<div className="text-xs text-dark-500 mt-1">
from {nnObjectives.length} predictions
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,185 @@
import { useState, useEffect } from 'react';
import { Activity, Clock, Cpu, Zap, CheckCircle } from 'lucide-react';
interface CurrentTrialProps {
studyId: string | null;
totalTrials: number;
completedTrials: number;
isRunning: boolean;
lastTrialTime?: number; // ms for last trial
}
type TrialPhase = 'idle' | 'sampling' | 'evaluating' | 'extracting' | 'complete';
export function CurrentTrialPanel({
studyId,
totalTrials,
completedTrials,
isRunning,
lastTrialTime
}: CurrentTrialProps) {
const [elapsedTime, setElapsedTime] = useState(0);
const [phase, setPhase] = useState<TrialPhase>('idle');
// Simulate phase progression when running
useEffect(() => {
if (!isRunning) {
setPhase('idle');
setElapsedTime(0);
return;
}
setPhase('sampling');
const interval = setInterval(() => {
setElapsedTime(prev => {
const newTime = prev + 1;
// Simulate phase transitions based on typical timing
if (newTime < 2) setPhase('sampling');
else if (newTime < 5) setPhase('evaluating');
else setPhase('extracting');
return newTime;
});
}, 1000);
return () => clearInterval(interval);
}, [isRunning, completedTrials]);
// Reset elapsed time when a new trial completes
useEffect(() => {
if (isRunning) {
setElapsedTime(0);
setPhase('sampling');
}
}, [completedTrials, isRunning]);
// Calculate ETA
const calculateETA = () => {
if (!isRunning || completedTrials === 0 || !lastTrialTime) return null;
const remainingTrials = totalTrials - completedTrials;
const avgTimePerTrial = lastTrialTime / 1000; // convert to seconds
const etaSeconds = remainingTrials * avgTimePerTrial;
if (etaSeconds < 60) return `~${Math.round(etaSeconds)}s`;
if (etaSeconds < 3600) return `~${Math.round(etaSeconds / 60)}m`;
return `~${(etaSeconds / 3600).toFixed(1)}h`;
};
const progressPercent = totalTrials > 0 ? (completedTrials / totalTrials) * 100 : 0;
const eta = calculateETA();
const getPhaseInfo = () => {
switch (phase) {
case 'sampling':
return { label: 'Sampling', color: 'text-blue-400', bgColor: 'bg-blue-500/20', icon: Zap };
case 'evaluating':
return { label: 'FEA Solving', color: 'text-yellow-400', bgColor: 'bg-yellow-500/20', icon: Cpu };
case 'extracting':
return { label: 'Extracting', color: 'text-purple-400', bgColor: 'bg-purple-500/20', icon: Activity };
case 'complete':
return { label: 'Complete', color: 'text-green-400', bgColor: 'bg-green-500/20', icon: CheckCircle };
default:
return { label: 'Idle', color: 'text-dark-400', bgColor: 'bg-dark-600', icon: Clock };
}
};
const phaseInfo = getPhaseInfo();
const PhaseIcon = phaseInfo.icon;
if (!studyId) return null;
return (
<div className="bg-dark-750 rounded-lg border border-dark-600 p-4">
{/* Header Row */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Activity className={`w-5 h-5 ${isRunning ? 'text-green-400 animate-pulse' : 'text-dark-400'}`} />
<span className="font-semibold text-white">
{isRunning ? `Trial #${completedTrials + 1}` : 'Optimization Status'}
</span>
</div>
{isRunning && (
<span className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${phaseInfo.bgColor} ${phaseInfo.color}`}>
<PhaseIcon className="w-3 h-3" />
{phaseInfo.label}
</span>
)}
</div>
{/* Progress Bar */}
<div className="mb-3">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-dark-400">Progress</span>
<span className="text-white font-medium">
{completedTrials} / {totalTrials} trials
</span>
</div>
<div className="h-2 bg-dark-600 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
isRunning ? 'bg-gradient-to-r from-primary-600 to-primary-400' : 'bg-primary-500'
}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
{/* Stats Row */}
<div className="grid grid-cols-3 gap-3">
{/* Elapsed Time */}
<div className="text-center">
<div className={`text-lg font-mono ${isRunning ? 'text-white' : 'text-dark-400'}`}>
{isRunning ? `${elapsedTime}s` : '--'}
</div>
<div className="text-xs text-dark-400">Elapsed</div>
</div>
{/* Completion */}
<div className="text-center border-x border-dark-600">
<div className="text-lg font-mono text-primary-400">
{progressPercent.toFixed(1)}%
</div>
<div className="text-xs text-dark-400">Complete</div>
</div>
{/* ETA */}
<div className="text-center">
<div className={`text-lg font-mono ${eta ? 'text-blue-400' : 'text-dark-400'}`}>
{eta || '--'}
</div>
<div className="text-xs text-dark-400">ETA</div>
</div>
</div>
{/* Running indicator */}
{isRunning && (
<div className="mt-3 pt-3 border-t border-dark-600">
<div className="flex items-center justify-center gap-2 text-xs text-green-400">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Optimization in progress...
</div>
</div>
)}
{/* Paused/Stopped indicator */}
{!isRunning && completedTrials > 0 && completedTrials < totalTrials && (
<div className="mt-3 pt-3 border-t border-dark-600">
<div className="flex items-center justify-center gap-2 text-xs text-orange-400">
<span className="w-2 h-2 bg-orange-500 rounded-full" />
Optimization paused
</div>
</div>
)}
{/* Completed indicator */}
{!isRunning && completedTrials >= totalTrials && totalTrials > 0 && (
<div className="mt-3 pt-3 border-t border-dark-600">
<div className="flex items-center justify-center gap-2 text-xs text-blue-400">
<CheckCircle className="w-3 h-3" />
Optimization complete
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,158 @@
import { Cpu, Layers, Target, TrendingUp, Database, Brain } from 'lucide-react';
interface OptimizerStatePanelProps {
sampler?: string;
nTrials: number;
completedTrials: number;
feaTrials?: number;
nnTrials?: number;
objectives?: Array<{ name: string; direction: string }>;
isMultiObjective: boolean;
paretoSize?: number;
}
export function OptimizerStatePanel({
sampler = 'TPESampler',
nTrials,
completedTrials,
feaTrials = 0,
nnTrials = 0,
objectives = [],
isMultiObjective,
paretoSize = 0
}: OptimizerStatePanelProps) {
// Determine optimizer phase based on progress
const getPhase = () => {
if (completedTrials === 0) return 'Initializing';
if (completedTrials < 10) return 'Exploration';
if (completedTrials < nTrials * 0.5) return 'Exploitation';
if (completedTrials < nTrials * 0.9) return 'Refinement';
return 'Convergence';
};
const phase = getPhase();
// Format sampler name for display
const formatSampler = (s: string) => {
const samplers: Record<string, string> = {
'TPESampler': 'TPE (Bayesian)',
'NSGAIISampler': 'NSGA-II',
'NSGAIIISampler': 'NSGA-III',
'CmaEsSampler': 'CMA-ES',
'RandomSampler': 'Random',
'GridSampler': 'Grid',
'QMCSampler': 'Quasi-Monte Carlo'
};
return samplers[s] || s;
};
return (
<div className="bg-dark-750 rounded-lg border border-dark-600 p-4">
{/* Header */}
<div className="flex items-center gap-2 mb-4">
<Cpu className="w-5 h-5 text-primary-400" />
<span className="font-semibold text-white">Optimizer State</span>
</div>
{/* Main Stats Grid */}
<div className="grid grid-cols-2 gap-3 mb-4">
{/* Sampler */}
<div className="bg-dark-700 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Target className="w-4 h-4 text-dark-400" />
<span className="text-xs text-dark-400 uppercase">Sampler</span>
</div>
<div className="text-sm font-medium text-white truncate" title={sampler}>
{formatSampler(sampler)}
</div>
</div>
{/* Phase */}
<div className="bg-dark-700 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-dark-400" />
<span className="text-xs text-dark-400 uppercase">Phase</span>
</div>
<div className={`text-sm font-medium ${
phase === 'Convergence' ? 'text-green-400' :
phase === 'Refinement' ? 'text-blue-400' :
phase === 'Exploitation' ? 'text-yellow-400' :
'text-primary-400'
}`}>
{phase}
</div>
</div>
</div>
{/* FEA vs NN Trials (for hybrid optimizations) */}
{(feaTrials > 0 || nnTrials > 0) && (
<div className="mb-4">
<div className="text-xs text-dark-400 uppercase mb-2">Trial Sources</div>
<div className="flex gap-2">
<div className="flex-1 bg-dark-700 rounded-lg p-2 text-center">
<Database className="w-4 h-4 text-blue-400 mx-auto mb-1" />
<div className="text-lg font-bold text-blue-400">{feaTrials}</div>
<div className="text-xs text-dark-400">FEA</div>
</div>
<div className="flex-1 bg-dark-700 rounded-lg p-2 text-center">
<Brain className="w-4 h-4 text-purple-400 mx-auto mb-1" />
<div className="text-lg font-bold text-purple-400">{nnTrials}</div>
<div className="text-xs text-dark-400">Neural Net</div>
</div>
</div>
{nnTrials > 0 && (
<div className="mt-2 text-xs text-dark-400 text-center">
{((nnTrials / (feaTrials + nnTrials)) * 100).toFixed(0)}% acceleration from surrogate
</div>
)}
</div>
)}
{/* Objectives */}
{objectives.length > 0 && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<Layers className="w-4 h-4 text-dark-400" />
<span className="text-xs text-dark-400 uppercase">
{isMultiObjective ? 'Multi-Objective' : 'Single Objective'}
</span>
</div>
<div className="space-y-1">
{objectives.slice(0, 3).map((obj, idx) => (
<div
key={idx}
className="flex items-center justify-between text-sm bg-dark-700 rounded px-2 py-1"
>
<span className="text-dark-300 truncate" title={obj.name}>
{obj.name.length > 20 ? obj.name.slice(0, 18) + '...' : obj.name}
</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${
obj.direction === 'minimize' ? 'bg-green-900/50 text-green-400' : 'bg-blue-900/50 text-blue-400'
}`}>
{obj.direction === 'minimize' ? 'min' : 'max'}
</span>
</div>
))}
{objectives.length > 3 && (
<div className="text-xs text-dark-500 text-center">
+{objectives.length - 3} more
</div>
)}
</div>
</div>
)}
{/* Pareto Front Size (for multi-objective) */}
{isMultiObjective && paretoSize > 0 && (
<div className="pt-3 border-t border-dark-600">
<div className="flex items-center justify-between">
<span className="text-xs text-dark-400">Pareto Front Size</span>
<span className="text-sm font-medium text-primary-400">
{paretoSize} solutions
</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { CurrentTrialPanel } from './CurrentTrialPanel';
export { OptimizerStatePanel } from './OptimizerStatePanel';

View File

@@ -8,6 +8,7 @@ interface StudyContextType {
studies: Study[];
refreshStudies: () => Promise<void>;
isLoading: boolean;
isInitialized: boolean; // True once initial load + localStorage restoration is complete
clearStudy: () => void;
}
@@ -17,6 +18,7 @@ export const StudyProvider: React.FC<{ children: ReactNode }> = ({ children }) =
const [selectedStudy, setSelectedStudyState] = useState<Study | null>(null);
const [studies, setStudies] = useState<Study[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const refreshStudies = async () => {
try {
@@ -55,16 +57,23 @@ export const StudyProvider: React.FC<{ children: ReactNode }> = ({ children }) =
// Initial load
useEffect(() => {
const init = async () => {
await refreshStudies();
// Restore last selected study
const lastStudyId = localStorage.getItem('selectedStudyId');
if (lastStudyId) {
try {
const response = await apiClient.getStudies();
const study = response.studies.find(s => s.id === lastStudyId);
if (study) {
setSelectedStudyState(study);
setStudies(response.studies);
// Restore last selected study from localStorage
const lastStudyId = localStorage.getItem('selectedStudyId');
if (lastStudyId) {
const study = response.studies.find(s => s.id === lastStudyId);
if (study) {
setSelectedStudyState(study);
}
}
} catch (error) {
console.error('Failed to initialize studies:', error);
} finally {
setIsLoading(false);
setIsInitialized(true); // Mark as initialized AFTER localStorage restoration
}
};
init();
@@ -77,6 +86,7 @@ export const StudyProvider: React.FC<{ children: ReactNode }> = ({ children }) =
studies,
refreshStudies,
isLoading,
isInitialized,
clearStudy
}}>
{children}

View File

@@ -0,0 +1,172 @@
import { useCallback, useEffect, useState } from 'react';
interface NotificationOptions {
title: string;
body: string;
icon?: string;
tag?: string;
requireInteraction?: boolean;
}
interface UseNotificationsReturn {
permission: NotificationPermission | 'unsupported';
requestPermission: () => Promise<boolean>;
showNotification: (options: NotificationOptions) => void;
isEnabled: boolean;
setEnabled: (enabled: boolean) => void;
}
const STORAGE_KEY = 'atomizer-notifications-enabled';
export function useNotifications(): UseNotificationsReturn {
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(
typeof Notification !== 'undefined' ? Notification.permission : 'unsupported'
);
const [isEnabled, setIsEnabledState] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
const stored = localStorage.getItem(STORAGE_KEY);
return stored === 'true';
});
// Update permission state when it changes
useEffect(() => {
if (typeof Notification === 'undefined') {
setPermission('unsupported');
return;
}
setPermission(Notification.permission);
}, []);
const requestPermission = useCallback(async (): Promise<boolean> => {
if (typeof Notification === 'undefined') {
console.warn('Notifications not supported in this browser');
return false;
}
if (Notification.permission === 'granted') {
setPermission('granted');
return true;
}
if (Notification.permission === 'denied') {
setPermission('denied');
return false;
}
try {
const result = await Notification.requestPermission();
setPermission(result);
return result === 'granted';
} catch (error) {
console.error('Error requesting notification permission:', error);
return false;
}
}, []);
const setEnabled = useCallback((enabled: boolean) => {
setIsEnabledState(enabled);
localStorage.setItem(STORAGE_KEY, enabled.toString());
}, []);
const showNotification = useCallback((options: NotificationOptions) => {
if (typeof Notification === 'undefined') {
console.warn('Notifications not supported');
return;
}
if (!isEnabled) {
return;
}
if (Notification.permission !== 'granted') {
console.warn('Notification permission not granted');
return;
}
try {
const notification = new Notification(options.title, {
body: options.body,
icon: options.icon || '/favicon.ico',
tag: options.tag,
requireInteraction: options.requireInteraction || false,
silent: false
});
// Auto close after 5 seconds unless requireInteraction is true
if (!options.requireInteraction) {
setTimeout(() => notification.close(), 5000);
}
// Focus window on click
notification.onclick = () => {
window.focus();
notification.close();
};
} catch (error) {
console.error('Error showing notification:', error);
}
}, [isEnabled]);
return {
permission,
requestPermission,
showNotification,
isEnabled,
setEnabled
};
}
// Notification types for optimization events
export interface OptimizationNotification {
type: 'new_best' | 'completed' | 'error' | 'milestone';
studyName: string;
message: string;
value?: number;
improvement?: number;
}
export function formatOptimizationNotification(notification: OptimizationNotification): NotificationOptions {
switch (notification.type) {
case 'new_best':
return {
title: `New Best Found - ${notification.studyName}`,
body: notification.improvement
? `${notification.message} (${notification.improvement.toFixed(1)}% improvement)`
: notification.message,
tag: `best-${notification.studyName}`,
requireInteraction: false
};
case 'completed':
return {
title: `Optimization Complete - ${notification.studyName}`,
body: notification.message,
tag: `complete-${notification.studyName}`,
requireInteraction: true
};
case 'error':
return {
title: `Error - ${notification.studyName}`,
body: notification.message,
tag: `error-${notification.studyName}`,
requireInteraction: true
};
case 'milestone':
return {
title: `Milestone Reached - ${notification.studyName}`,
body: notification.message,
tag: `milestone-${notification.studyName}`,
requireInteraction: false
};
default:
return {
title: notification.studyName,
body: notification.message
};
}
}
export default useNotifications;

View File

@@ -0,0 +1,757 @@
import { useState, useEffect, lazy, Suspense, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
BarChart3,
TrendingUp,
Grid3X3,
Target,
Filter,
Brain,
RefreshCw,
Download,
Layers,
LucideIcon
} from 'lucide-react';
import { useStudy } from '../context/StudyContext';
import { Card } from '../components/common/Card';
// Lazy load charts
const PlotlyParetoPlot = lazy(() => import('../components/plotly/PlotlyParetoPlot').then(m => ({ default: m.PlotlyParetoPlot })));
const PlotlyParallelCoordinates = lazy(() => import('../components/plotly/PlotlyParallelCoordinates').then(m => ({ default: m.PlotlyParallelCoordinates })));
const PlotlyParameterImportance = lazy(() => import('../components/plotly/PlotlyParameterImportance').then(m => ({ default: m.PlotlyParameterImportance })));
const PlotlyConvergencePlot = lazy(() => import('../components/plotly/PlotlyConvergencePlot').then(m => ({ default: m.PlotlyConvergencePlot })));
const PlotlyCorrelationHeatmap = lazy(() => import('../components/plotly/PlotlyCorrelationHeatmap').then(m => ({ default: m.PlotlyCorrelationHeatmap })));
const PlotlyFeasibilityChart = lazy(() => import('../components/plotly/PlotlyFeasibilityChart').then(m => ({ default: m.PlotlyFeasibilityChart })));
const PlotlySurrogateQuality = lazy(() => import('../components/plotly/PlotlySurrogateQuality').then(m => ({ default: m.PlotlySurrogateQuality })));
const PlotlyRunComparison = lazy(() => import('../components/plotly/PlotlyRunComparison').then(m => ({ default: m.PlotlyRunComparison })));
const ChartLoading = () => (
<div className="flex items-center justify-center h-64 text-dark-400">
<div className="animate-pulse">Loading chart...</div>
</div>
);
type AnalysisTab = 'overview' | 'parameters' | 'pareto' | 'correlations' | 'constraints' | 'surrogate' | 'runs';
interface RunData {
run_id: number;
name: string;
source: 'FEA' | 'NN';
trial_count: number;
best_value: number | null;
avg_value: number | null;
first_trial: string | null;
last_trial: string | null;
}
interface TrialData {
trial_number: number;
values: number[];
params: Record<string, number>;
user_attrs?: Record<string, any>;
constraint_satisfied?: boolean;
source?: 'FEA' | 'NN' | 'V10_FEA';
}
interface ObjectiveData {
name: string;
direction: 'minimize' | 'maximize';
}
interface StudyMetadata {
objectives?: ObjectiveData[];
design_variables?: Array<{ name: string; min?: number; max?: number }>;
sampler?: string;
description?: string;
}
export default function Analysis() {
const navigate = useNavigate();
const { selectedStudy, isInitialized } = useStudy();
const [activeTab, setActiveTab] = useState<AnalysisTab>('overview');
const [loading, setLoading] = useState(true);
const [trials, setTrials] = useState<TrialData[]>([]);
const [metadata, setMetadata] = useState<StudyMetadata | null>(null);
const [paretoFront, setParetoFront] = useState<any[]>([]);
const [runs, setRuns] = useState<RunData[]>([]);
// Redirect if no study selected
useEffect(() => {
if (isInitialized && !selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate, isInitialized]);
// Load study data
useEffect(() => {
if (!selectedStudy) return;
const loadData = async () => {
setLoading(true);
try {
// Load trial history
const historyRes = await fetch(`/api/optimization/studies/${selectedStudy.id}/history?limit=500`);
const historyData = await historyRes.json();
const trialsData = historyData.trials.map((t: any) => {
let values: number[] = [];
if (t.objectives && Array.isArray(t.objectives)) {
values = t.objectives;
} else if (t.objective !== null && t.objective !== undefined) {
values = [t.objective];
}
const rawSource = t.source || t.user_attrs?.source || 'FEA';
const source: 'FEA' | 'NN' | 'V10_FEA' = rawSource === 'NN' ? 'NN' : rawSource === 'V10_FEA' ? 'V10_FEA' : 'FEA';
return {
trial_number: t.trial_number,
values,
params: t.design_variables || {},
user_attrs: t.user_attrs || {},
constraint_satisfied: t.constraint_satisfied !== false,
source
};
});
setTrials(trialsData);
// Load metadata
const metadataRes = await fetch(`/api/optimization/studies/${selectedStudy.id}/metadata`);
const metadataData = await metadataRes.json();
setMetadata(metadataData);
// Load Pareto front
const paretoRes = await fetch(`/api/optimization/studies/${selectedStudy.id}/pareto-front`);
const paretoData = await paretoRes.json();
if (paretoData.is_multi_objective && paretoData.pareto_front) {
setParetoFront(paretoData.pareto_front);
}
// Load runs data for comparison
const runsRes = await fetch(`/api/optimization/studies/${selectedStudy.id}/runs`);
const runsData = await runsRes.json();
if (runsData.runs) {
setRuns(runsData.runs);
}
} catch (err) {
console.error('Failed to load analysis data:', err);
} finally {
setLoading(false);
}
};
loadData();
}, [selectedStudy]);
// Calculate statistics
const stats = useMemo(() => {
if (trials.length === 0) return null;
const objectives = trials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
if (objectives.length === 0) return null;
const sorted = [...objectives].sort((a, b) => a - b);
const min = sorted[0];
const max = sorted[sorted.length - 1];
const mean = objectives.reduce((a, b) => a + b, 0) / objectives.length;
const median = sorted[Math.floor(sorted.length / 2)];
const stdDev = Math.sqrt(objectives.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / objectives.length);
const p25 = sorted[Math.floor(sorted.length * 0.25)];
const p75 = sorted[Math.floor(sorted.length * 0.75)];
const p90 = sorted[Math.floor(sorted.length * 0.90)];
const feaTrials = trials.filter(t => t.source === 'FEA').length;
const nnTrials = trials.filter(t => t.source === 'NN').length;
const feasible = trials.filter(t => t.constraint_satisfied).length;
return {
min,
max,
mean,
median,
stdDev,
p25,
p75,
p90,
feaTrials,
nnTrials,
feasible,
total: trials.length,
feasibilityRate: (feasible / trials.length) * 100
};
}, [trials]);
// Tabs configuration
const tabs: { id: AnalysisTab; label: string; icon: LucideIcon; disabled?: boolean }[] = [
{ id: 'overview', label: 'Overview', icon: BarChart3 },
{ id: 'parameters', label: 'Parameters', icon: TrendingUp },
{ id: 'pareto', label: 'Pareto', icon: Target, disabled: (metadata?.objectives?.length || 0) <= 1 },
{ id: 'correlations', label: 'Correlations', icon: Grid3X3 },
{ id: 'constraints', label: 'Constraints', icon: Filter },
{ id: 'surrogate', label: 'Surrogate', icon: Brain, disabled: trials.filter(t => t.source === 'NN').length === 0 },
{ id: 'runs', label: 'Runs', icon: Layers, disabled: runs.length <= 1 },
];
// Export data
const handleExportCSV = () => {
if (trials.length === 0) return;
const paramNames = Object.keys(trials[0].params);
const headers = ['trial', 'objective', ...paramNames, 'source', 'feasible'].join(',');
const rows = trials.map(t => [
t.trial_number,
t.values[0],
...paramNames.map(p => t.params[p]),
t.source,
t.constraint_satisfied
].join(','));
const csv = [headers, ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedStudy?.id}_analysis.csv`;
a.click();
URL.revokeObjectURL(url);
};
if (!isInitialized || !selectedStudy) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-dark-400">Loading...</p>
</div>
</div>
);
}
const isMultiObjective = (metadata?.objectives?.length || 0) > 1;
return (
<div className="w-full max-w-[2400px] mx-auto px-4">
{/* 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">Analysis</h1>
<p className="text-dark-400 text-sm">Deep analysis for {selectedStudy.name || selectedStudy.id}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleExportCSV}
className="flex items-center gap-2 px-4 py-2 bg-dark-700 hover:bg-dark-600 text-white rounded-lg transition-colors"
disabled={trials.length === 0}
>
<Download className="w-4 h-4" />
Export CSV
</button>
</div>
</header>
{/* Tab Navigation */}
<div className="flex gap-1 mb-6 border-b border-dark-600 overflow-x-auto">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => !tab.disabled && setActiveTab(tab.id)}
disabled={tab.disabled}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === tab.id
? 'text-primary-400 border-b-2 border-primary-400 -mb-[2px]'
: tab.disabled
? 'text-dark-600 cursor-not-allowed'
: 'text-dark-400 hover:text-white'
}`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<RefreshCw className="w-8 h-8 animate-spin text-dark-400" />
</div>
) : (
<>
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Summary Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Total Trials</div>
<div className="text-2xl font-bold text-white">{stats.total}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Best Value</div>
<div className="text-2xl font-bold text-green-400">{stats.min.toExponential(3)}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Mean</div>
<div className="text-2xl font-bold text-white">{stats.mean.toExponential(3)}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Median</div>
<div className="text-2xl font-bold text-white">{stats.median.toExponential(3)}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Std Dev</div>
<div className="text-2xl font-bold text-white">{stats.stdDev.toExponential(3)}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Feasibility</div>
<div className="text-2xl font-bold text-primary-400">{stats.feasibilityRate.toFixed(1)}%</div>
</Card>
</div>
)}
{/* Percentile Distribution */}
{stats && (
<Card title="Objective Distribution">
<div className="grid grid-cols-4 gap-4 mb-4">
<div className="text-center p-3 bg-dark-750 rounded-lg">
<div className="text-xs text-dark-400 mb-1">Min</div>
<div className="text-lg font-mono text-green-400">{stats.min.toExponential(3)}</div>
</div>
<div className="text-center p-3 bg-dark-750 rounded-lg">
<div className="text-xs text-dark-400 mb-1">25th %</div>
<div className="text-lg font-mono text-white">{stats.p25.toExponential(3)}</div>
</div>
<div className="text-center p-3 bg-dark-750 rounded-lg">
<div className="text-xs text-dark-400 mb-1">75th %</div>
<div className="text-lg font-mono text-white">{stats.p75.toExponential(3)}</div>
</div>
<div className="text-center p-3 bg-dark-750 rounded-lg">
<div className="text-xs text-dark-400 mb-1">90th %</div>
<div className="text-lg font-mono text-white">{stats.p90.toExponential(3)}</div>
</div>
</div>
</Card>
)}
{/* Convergence Plot */}
{trials.length > 0 && (
<Card title="Convergence Plot">
<Suspense fallback={<ChartLoading />}>
<PlotlyConvergencePlot
trials={trials}
objectiveIndex={0}
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
direction="minimize"
height={350}
/>
</Suspense>
</Card>
)}
{/* Best Trials Table */}
<Card title="Top 10 Best Trials">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Rank</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trial</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Objective</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
{Object.keys(trials[0]?.params || {}).slice(0, 3).map(p => (
<th key={p} className="text-left py-2 px-3 text-dark-400 font-medium">{p}</th>
))}
</tr>
</thead>
<tbody>
{[...trials]
.sort((a, b) => (a.values[0] ?? Infinity) - (b.values[0] ?? Infinity))
.slice(0, 10)
.map((trial, idx) => (
<tr key={trial.trial_number} className="border-b border-dark-700">
<td className="py-2 px-3">
<span className={`inline-flex w-6 h-6 items-center justify-center rounded-full text-xs font-bold ${
idx === 0 ? 'bg-yellow-500/20 text-yellow-400' :
idx === 1 ? 'bg-gray-400/20 text-gray-300' :
idx === 2 ? 'bg-orange-700/20 text-orange-400' :
'bg-dark-600 text-dark-400'
}`}>
{idx + 1}
</span>
</td>
<td className="py-2 px-3 font-mono text-white">#{trial.trial_number}</td>
<td className="py-2 px-3 font-mono text-green-400">{trial.values[0]?.toExponential(4)}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
trial.source === 'NN' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
}`}>
{trial.source}
</span>
</td>
{Object.keys(trials[0]?.params || {}).slice(0, 3).map(p => (
<td key={p} className="py-2 px-3 font-mono text-dark-300">
{trial.params[p]?.toFixed(4)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
{/* Parameters Tab */}
{activeTab === 'parameters' && (
<div className="space-y-6">
{/* Parameter Importance */}
{trials.length > 0 && metadata?.design_variables && (
<Card title="Parameter Importance">
<Suspense fallback={<ChartLoading />}>
<PlotlyParameterImportance
trials={trials}
designVariables={metadata.design_variables}
objectiveIndex={0}
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
height={400}
/>
</Suspense>
</Card>
)}
{/* Parallel Coordinates */}
{trials.length > 0 && metadata && (
<Card title="Parallel Coordinates">
<Suspense fallback={<ChartLoading />}>
<PlotlyParallelCoordinates
trials={trials}
objectives={metadata.objectives || []}
designVariables={metadata.design_variables || []}
paretoFront={paretoFront}
height={450}
/>
</Suspense>
</Card>
)}
</div>
)}
{/* Pareto Tab */}
{activeTab === 'pareto' && isMultiObjective && (
<div className="space-y-6">
{/* Pareto Metrics */}
<div className="grid grid-cols-3 gap-4">
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Pareto Solutions</div>
<div className="text-2xl font-bold text-primary-400">{paretoFront.length}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Objectives</div>
<div className="text-2xl font-bold text-white">{metadata?.objectives?.length || 0}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Dominated Ratio</div>
<div className="text-2xl font-bold text-white">
{trials.length > 0 ? ((1 - paretoFront.length / trials.length) * 100).toFixed(1) : 0}%
</div>
</Card>
</div>
{/* Pareto Front Plot */}
{paretoFront.length > 0 && (
<Card title="Pareto Front">
<Suspense fallback={<ChartLoading />}>
<PlotlyParetoPlot
trials={trials}
paretoFront={paretoFront}
objectives={metadata?.objectives || []}
height={500}
/>
</Suspense>
</Card>
)}
{/* Pareto Solutions Table */}
<Card title="Pareto-Optimal Solutions">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trial</th>
{metadata?.objectives?.map(obj => (
<th key={obj.name} className="text-left py-2 px-3 text-dark-400 font-medium">{obj.name}</th>
))}
</tr>
</thead>
<tbody>
{paretoFront.slice(0, 20).map((sol, idx) => (
<tr key={idx} className="border-b border-dark-700">
<td className="py-2 px-3 font-mono text-white">#{sol.trial_number}</td>
{sol.values?.map((v: number, i: number) => (
<td key={i} className="py-2 px-3 font-mono text-primary-400">{v?.toExponential(4)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
{/* Correlations Tab */}
{activeTab === 'correlations' && (
<div className="space-y-6">
{/* Correlation Heatmap */}
{trials.length > 2 && (
<Card title="Parameter-Objective Correlation Matrix">
<Suspense fallback={<ChartLoading />}>
<PlotlyCorrelationHeatmap
trials={trials}
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
height={Math.min(500, 100 + Object.keys(trials[0]?.params || {}).length * 40)}
/>
</Suspense>
</Card>
)}
{/* Correlation Interpretation Guide */}
<Card title="Interpreting Correlations">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div className="p-3 bg-blue-500/10 rounded-lg border border-blue-500/30">
<div className="text-blue-400 font-semibold mb-1">Strong Positive (0.7 to 1.0)</div>
<p className="text-dark-400 text-xs">Increasing parameter increases objective</p>
</div>
<div className="p-3 bg-blue-500/5 rounded-lg border border-blue-500/20">
<div className="text-blue-300 font-semibold mb-1">Moderate Positive (0.3 to 0.7)</div>
<p className="text-dark-400 text-xs">Some positive relationship</p>
</div>
<div className="p-3 bg-red-500/5 rounded-lg border border-red-500/20">
<div className="text-red-300 font-semibold mb-1">Moderate Negative (-0.7 to -0.3)</div>
<p className="text-dark-400 text-xs">Some negative relationship</p>
</div>
<div className="p-3 bg-red-500/10 rounded-lg border border-red-500/30">
<div className="text-red-400 font-semibold mb-1">Strong Negative (-1.0 to -0.7)</div>
<p className="text-dark-400 text-xs">Increasing parameter decreases objective</p>
</div>
</div>
</Card>
{/* Top Correlations Table */}
{trials.length > 2 && (
<Card title="Strongest Parameter Correlations with Objective">
<CorrelationTable trials={trials} objectiveName={metadata?.objectives?.[0]?.name || 'Objective'} />
</Card>
)}
</div>
)}
{/* Constraints Tab */}
{activeTab === 'constraints' && stats && (
<div className="space-y-6">
<div className="grid grid-cols-3 gap-4">
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Feasible Trials</div>
<div className="text-2xl font-bold text-green-400">{stats.feasible}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Infeasible Trials</div>
<div className="text-2xl font-bold text-red-400">{stats.total - stats.feasible}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Feasibility Rate</div>
<div className="text-2xl font-bold text-primary-400">{stats.feasibilityRate.toFixed(1)}%</div>
</Card>
</div>
{/* Feasibility Over Time Chart */}
<Card title="Feasibility Rate Over Time">
<Suspense fallback={<ChartLoading />}>
<PlotlyFeasibilityChart trials={trials} height={350} />
</Suspense>
</Card>
{/* Infeasible Trials List */}
{stats.total - stats.feasible > 0 && (
<Card title="Recent Infeasible Trials">
<div className="overflow-x-auto max-h-64">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-dark-800">
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trial</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Objective</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
</tr>
</thead>
<tbody>
{trials
.filter(t => !t.constraint_satisfied)
.slice(-20)
.reverse()
.map(trial => (
<tr key={trial.trial_number} className="border-b border-dark-700">
<td className="py-2 px-3 font-mono text-white">#{trial.trial_number}</td>
<td className="py-2 px-3 font-mono text-red-400">{trial.values[0]?.toExponential(4) || 'N/A'}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
trial.source === 'NN' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
}`}>
{trial.source}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
)}
{/* Surrogate Tab */}
{activeTab === 'surrogate' && stats && (
<div className="space-y-6">
<div className="grid grid-cols-4 gap-4">
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">FEA Evaluations</div>
<div className="text-2xl font-bold text-blue-400">{stats.feaTrials}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">NN Predictions</div>
<div className="text-2xl font-bold text-purple-400">{stats.nnTrials}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">NN Ratio</div>
<div className="text-2xl font-bold text-green-400">
{stats.nnTrials > 0 ? `${((stats.nnTrials / stats.total) * 100).toFixed(0)}%` : '0%'}
</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Speedup Factor</div>
<div className="text-2xl font-bold text-primary-400">
{stats.feaTrials > 0 ? `${(stats.total / stats.feaTrials).toFixed(1)}x` : '1.0x'}
</div>
</Card>
</div>
{/* Surrogate Quality Charts */}
<Card title="Surrogate Model Analysis">
<Suspense fallback={<ChartLoading />}>
<PlotlySurrogateQuality trials={trials} height={400} />
</Suspense>
</Card>
</div>
)}
{/* Runs Tab */}
{activeTab === 'runs' && runs.length > 0 && (
<div className="space-y-6">
<Card title="Optimization Runs Comparison">
<p className="text-dark-400 text-sm mb-4">
Compare different optimization runs within this study. Studies with adaptive optimization
may have multiple runs (e.g., initial FEA exploration, NN-accelerated iterations).
</p>
<Suspense fallback={<ChartLoading />}>
<PlotlyRunComparison runs={runs} height={400} />
</Suspense>
</Card>
</div>
)}
</>
)}
</div>
);
}
// Helper component for correlation table
function CorrelationTable({ trials, objectiveName }: { trials: TrialData[]; objectiveName: string }) {
const correlations = useMemo(() => {
if (trials.length < 3) return [];
const paramNames = Object.keys(trials[0].params);
const objectives = trials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
const results: { param: string; correlation: number; absCorr: number }[] = [];
paramNames.forEach(param => {
const paramValues = trials.map(t => t.params[param]).filter(v => v !== undefined && !isNaN(v));
const minLen = Math.min(paramValues.length, objectives.length);
if (minLen < 3) return;
// Calculate Pearson correlation
const x = paramValues.slice(0, minLen);
const y = objectives.slice(0, minLen);
const n = x.length;
const meanX = x.reduce((a, b) => a + b, 0) / n;
const meanY = y.reduce((a, b) => a + b, 0) / n;
let numerator = 0;
let denomX = 0;
let denomY = 0;
for (let i = 0; i < n; i++) {
const dx = x[i] - meanX;
const dy = y[i] - meanY;
numerator += dx * dy;
denomX += dx * dx;
denomY += dy * dy;
}
const denominator = Math.sqrt(denomX) * Math.sqrt(denomY);
const corr = denominator === 0 ? 0 : numerator / denominator;
results.push({ param, correlation: corr, absCorr: Math.abs(corr) });
});
return results.sort((a, b) => b.absCorr - a.absCorr);
}, [trials]);
if (correlations.length === 0) {
return <p className="text-dark-400 text-center py-4">Not enough data for correlation analysis</p>;
}
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Parameter</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Correlation with {objectiveName}</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Strength</th>
</tr>
</thead>
<tbody>
{correlations.slice(0, 10).map(({ param, correlation, absCorr }) => (
<tr key={param} className="border-b border-dark-700">
<td className="py-2 px-3 font-mono text-white">{param}</td>
<td className="py-2 px-3">
<div className="flex items-center gap-2">
<div className="w-24 h-2 bg-dark-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${correlation > 0 ? 'bg-blue-500' : 'bg-red-500'}`}
style={{ width: `${absCorr * 100}%`, marginLeft: correlation < 0 ? 'auto' : 0 }}
/>
</div>
<span className={`font-mono ${
absCorr > 0.7 ? 'text-white font-bold' :
absCorr > 0.3 ? 'text-dark-200' : 'text-dark-400'
}`}>
{correlation > 0 ? '+' : ''}{correlation.toFixed(3)}
</span>
</div>
</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
absCorr > 0.7 ? 'bg-primary-500/20 text-primary-400' :
absCorr > 0.3 ? 'bg-yellow-500/20 text-yellow-400' :
'bg-dark-600 text-dark-400'
}`}>
{absCorr > 0.7 ? 'Strong' : absCorr > 0.3 ? 'Moderate' : 'Weak'}
</span>
</td>
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -1,12 +1,15 @@
import { useState, useEffect, lazy, Suspense } from 'react';
import { useState, useEffect, lazy, Suspense, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Terminal } from 'lucide-react';
import { Terminal, Settings } from 'lucide-react';
import { useOptimizationWebSocket } from '../hooks/useWebSocket';
import { useNotifications, formatOptimizationNotification } from '../hooks/useNotifications';
import { apiClient } from '../api/client';
import { useStudy } from '../context/StudyContext';
import { useClaudeTerminal } from '../context/ClaudeTerminalContext';
import { Card } from '../components/common/Card';
import { ControlPanel } from '../components/dashboard/ControlPanel';
import { NotificationSettings } from '../components/NotificationSettings';
import { ConfigEditor } from '../components/ConfigEditor';
import { ParetoPlot } from '../components/ParetoPlot';
import { ParallelCoordinatesPlot } from '../components/ParallelCoordinatesPlot';
import { ParameterImportanceChart } from '../components/ParameterImportanceChart';
@@ -14,6 +17,7 @@ import { ConvergencePlot } from '../components/ConvergencePlot';
import { StudyReportViewer } from '../components/StudyReportViewer';
import { ConsoleOutput } from '../components/ConsoleOutput';
import { ExpandableChart } from '../components/ExpandableChart';
import { CurrentTrialPanel, OptimizerStatePanel } from '../components/tracker';
import type { Trial } from '../types';
// Lazy load Plotly components for better initial load performance
@@ -31,16 +35,10 @@ const ChartLoading = () => (
export default function Dashboard() {
const navigate = useNavigate();
const { selectedStudy, refreshStudies } = useStudy();
const { selectedStudy, refreshStudies, isInitialized } = useStudy();
const selectedStudyId = selectedStudy?.id || null;
// Redirect to home if no study selected
useEffect(() => {
if (!selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate]);
// All hooks must be declared before any conditional returns
const [allTrials, setAllTrials] = useState<Trial[]>([]);
const [displayedTrials, setDisplayedTrials] = useState<Trial[]>([]);
const [bestValue, setBestValue] = useState<number>(Infinity);
@@ -52,9 +50,9 @@ export default function Dashboard() {
const [trialsPage, setTrialsPage] = useState(0);
const trialsPerPage = 50; // Limit trials per page for performance
// Parameter Space axis selection
const [paramXIndex, setParamXIndex] = useState(0);
const [paramYIndex, setParamYIndex] = useState(1);
// Parameter Space axis selection (reserved for future use)
const [_paramXIndex, _setParamXIndex] = useState(0);
const [_paramYIndex, _setParamYIndex] = useState(1);
// Protocol 13: New state for metadata and Pareto front
const [studyMetadata, setStudyMetadata] = useState<any>(null);
@@ -64,9 +62,27 @@ export default function Dashboard() {
// Chart library toggle: 'recharts' (faster) or 'plotly' (more interactive but slower)
const [chartLibrary, setChartLibrary] = useState<'plotly' | 'recharts'>('recharts');
// Process status for tracker panels
const [isRunning, setIsRunning] = useState(false);
const [lastTrialTime, _setLastTrialTime] = useState<number | undefined>(undefined);
// Config editor modal
const [showConfigEditor, setShowConfigEditor] = useState(false);
// Claude terminal from global context
const { isOpen: claudeTerminalOpen, setIsOpen: setClaudeTerminalOpen, isConnected: claudeConnected } = useClaudeTerminal();
// Desktop notifications
const { showNotification } = useNotifications();
const previousBestRef = useRef<number>(Infinity);
// Redirect to home if no study selected (but only after initialization completes)
useEffect(() => {
if (isInitialized && !selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate, isInitialized]);
const showAlert = (type: 'success' | 'warning', message: string) => {
const id = alertIdCounter;
setAlertIdCounter(prev => prev + 1);
@@ -84,8 +100,22 @@ export default function Dashboard() {
const trial = msg.data as Trial;
setAllTrials(prev => [...prev, trial]);
if (trial.objective !== null && trial.objective !== undefined && trial.objective < bestValue) {
const improvement = previousBestRef.current !== Infinity
? ((previousBestRef.current - trial.objective) / Math.abs(previousBestRef.current)) * 100
: 0;
setBestValue(trial.objective);
previousBestRef.current = trial.objective;
showAlert('success', `New best: ${trial.objective.toFixed(4)} (Trial #${trial.trial_number})`);
// Desktop notification for new best
showNotification(formatOptimizationNotification({
type: 'new_best',
studyName: selectedStudy?.name || selectedStudyId || 'Study',
message: `Best value: ${trial.objective.toExponential(4)}`,
value: trial.objective,
improvement
}));
}
} else if (msg.type === 'trial_pruned') {
setPrunedCount(prev => prev + 1);
@@ -162,9 +192,31 @@ export default function Dashboard() {
}
})
.catch(err => console.error('Failed to load Pareto front:', err));
// Check process status
apiClient.getProcessStatus(selectedStudyId)
.then(data => {
setIsRunning(data.is_running);
})
.catch(err => console.error('Failed to load process status:', err));
}
}, [selectedStudyId]);
// Poll process status periodically
useEffect(() => {
if (!selectedStudyId) return;
const pollStatus = setInterval(() => {
apiClient.getProcessStatus(selectedStudyId)
.then(data => {
setIsRunning(data.is_running);
})
.catch(() => {});
}, 5000);
return () => clearInterval(pollStatus);
}, [selectedStudyId]);
// Sort trials based on selected sort order
useEffect(() => {
let sorted = [...allTrials];
@@ -223,50 +275,19 @@ export default function Dashboard() {
return () => clearInterval(refreshInterval);
}, [selectedStudyId]);
// Sample data for charts when there are too many trials (performance optimization)
const MAX_CHART_POINTS = 200; // Reduced for better performance
const sampleData = <T,>(data: T[], maxPoints: number): T[] => {
if (data.length <= maxPoints) return data;
const step = Math.ceil(data.length / maxPoints);
return data.filter((_, i) => i % step === 0 || i === data.length - 1);
};
// Show loading state while initializing (restoring study from localStorage)
if (!isInitialized) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-dark-400">Loading study...</p>
</div>
</div>
);
}
// Prepare chart data with proper null/undefined handling
const allValidTrials = allTrials
.filter(t => t.objective !== null && t.objective !== undefined)
.sort((a, b) => a.trial_number - b.trial_number);
// Calculate best_so_far for each trial
let runningBest = Infinity;
const convergenceDataFull: ConvergenceDataPoint[] = allValidTrials.map(trial => {
if (trial.objective < runningBest) {
runningBest = trial.objective;
}
return {
trial_number: trial.trial_number,
objective: trial.objective,
best_so_far: runningBest,
};
});
// Sample for chart rendering performance
const convergenceData = sampleData(convergenceDataFull, MAX_CHART_POINTS);
const parameterSpaceDataFull: ParameterSpaceDataPoint[] = allTrials
.filter(t => t.objective !== null && t.objective !== undefined && t.design_variables)
.map(trial => {
const params = Object.values(trial.design_variables);
return {
trial_number: trial.trial_number,
x: params[paramXIndex] || 0,
y: params[paramYIndex] || 0,
objective: trial.objective,
isBest: trial.objective === bestValue,
};
});
// Sample for chart rendering performance
const parameterSpaceData = sampleData(parameterSpaceDataFull, MAX_CHART_POINTS);
// Note: Chart data sampling is handled by individual chart components
// Calculate average objective
const validObjectives = allTrials.filter(t => t.objective !== null && t.objective !== undefined).map(t => t.objective);
@@ -350,6 +371,21 @@ export default function Dashboard() {
<p className="text-dark-300 mt-1">Real-time optimization monitoring</p>
</div>
<div className="flex gap-2">
{/* Config Editor Button */}
{selectedStudyId && (
<button
onClick={() => setShowConfigEditor(true)}
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs bg-dark-700 text-dark-400 hover:bg-dark-600 hover:text-white transition-colors"
title="Edit study configuration"
>
<Settings className="w-4 h-4" />
<span className="hidden sm:inline">Config</span>
</button>
)}
{/* Notification Toggle */}
<NotificationSettings compact />
{/* Claude Code Terminal Toggle Button */}
<button
onClick={() => setClaudeTerminalOpen(!claudeTerminalOpen)}
@@ -421,6 +457,27 @@ export default function Dashboard() {
<ControlPanel onStatusChange={refreshStudies} horizontal />
</div>
{/* Tracker Panels - Current Trial and Optimizer State */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<CurrentTrialPanel
studyId={selectedStudyId}
totalTrials={selectedStudy?.progress.total || 100}
completedTrials={allTrials.length}
isRunning={isRunning}
lastTrialTime={lastTrialTime}
/>
<OptimizerStatePanel
sampler={studyMetadata?.sampler}
nTrials={selectedStudy?.progress.total || 100}
completedTrials={allTrials.length}
feaTrials={allTrialsRaw.filter(t => t.source === 'FEA').length}
nnTrials={allTrialsRaw.filter(t => t.source === 'NN').length}
objectives={studyMetadata?.objectives || []}
isMultiObjective={(studyMetadata?.objectives?.length || 0) > 1}
paretoSize={paretoFront.length}
/>
</div>
{/* Main Layout: Charts (Claude Terminal is now global/floating) */}
<div className="grid gap-4 grid-cols-1">
{/* Main Content - Charts stacked vertically */}
@@ -795,6 +852,15 @@ export default function Dashboard() {
</div>
</main>
</div>
{/* Config Editor Modal */}
{showConfigEditor && selectedStudyId && (
<ConfigEditor
studyId={selectedStudyId}
onClose={() => setShowConfigEditor(false)}
onSaved={() => refreshStudies()}
/>
)}
</div>
);
}

View File

@@ -1,38 +1,33 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
FolderOpen,
Play,
Pause,
CheckCircle,
Clock,
AlertCircle,
ArrowRight,
RefreshCw,
Zap,
FileText,
ChevronDown,
ChevronUp,
Target,
Activity
Activity,
BarChart3,
TrendingUp,
ArrowRight
} from 'lucide-react';
import { useStudy } from '../context/StudyContext';
import { Study } from '../types';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { apiClient } from '../api/client';
import { MarkdownRenderer } from '../components/MarkdownRenderer';
const Home: React.FC = () => {
const { studies, setSelectedStudy, refreshStudies, isLoading } = useStudy();
const [selectedPreview, setSelectedPreview] = useState<Study | null>(null);
const [readme, setReadme] = useState<string>('');
const [readmeLoading, setReadmeLoading] = useState(false);
const [showAllStudies, setShowAllStudies] = useState(false);
const [sortField, setSortField] = useState<'name' | 'status' | 'trials' | 'bestValue'>('trials');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const navigate = useNavigate();
// Load README when a study is selected for preview
@@ -49,7 +44,7 @@ const Home: React.FC = () => {
try {
const response = await apiClient.getStudyReadme(studyId);
setReadme(response.content || 'No README found for this study.');
} catch (error) {
} catch {
setReadme('No README found for this study.');
} finally {
setReadmeLoading(false);
@@ -61,78 +56,88 @@ const Home: React.FC = () => {
navigate('/dashboard');
};
const handleSort = (field: typeof sortField) => {
if (sortField === field) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDir('desc');
}
};
// Sort studies
const sortedStudies = useMemo(() => {
return [...studies].sort((a, b) => {
let aVal: any, bVal: any;
switch (sortField) {
case 'name':
aVal = (a.name || a.id).toLowerCase();
bVal = (b.name || b.id).toLowerCase();
break;
case 'trials':
aVal = a.progress.current;
bVal = b.progress.current;
break;
case 'bestValue':
aVal = a.best_value ?? Infinity;
bVal = b.best_value ?? Infinity;
break;
case 'status':
const statusOrder = { running: 0, paused: 1, completed: 2, not_started: 3 };
aVal = statusOrder[a.status as keyof typeof statusOrder] ?? 4;
bVal = statusOrder[b.status as keyof typeof statusOrder] ?? 4;
break;
default:
return 0;
}
if (sortDir === 'asc') {
return aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
} else {
return aVal > bVal ? -1 : aVal < bVal ? 1 : 0;
}
});
}, [studies, sortField, sortDir]);
// Aggregate stats
const aggregateStats = useMemo(() => {
const totalStudies = studies.length;
const runningStudies = studies.filter(s => s.status === 'running').length;
const completedStudies = studies.filter(s => s.status === 'completed').length;
const totalTrials = studies.reduce((sum, s) => sum + s.progress.current, 0);
const studiesWithValues = studies.filter(s => s.best_value !== null);
const bestOverall = studiesWithValues.length > 0
? studiesWithValues.reduce((best, curr) =>
(curr.best_value! < best.best_value!) ? curr : best
)
: null;
return { totalStudies, runningStudies, completedStudies, totalTrials, bestOverall };
}, [studies]);
const getStatusIcon = (status: string) => {
switch (status) {
case 'running':
return <Play className="w-3.5 h-3.5" />;
return <Play className="w-4 h-4 text-green-400" />;
case 'paused':
return <Pause className="w-3.5 h-3.5" />;
return <Pause className="w-4 h-4 text-orange-400" />;
case 'completed':
return <CheckCircle className="w-3.5 h-3.5" />;
case 'not_started':
return <Clock className="w-3.5 h-3.5" />;
return <CheckCircle className="w-4 h-4 text-blue-400" />;
default:
return <AlertCircle className="w-3.5 h-3.5" />;
return <Clock className="w-4 h-4 text-dark-400" />;
}
};
const getStatusStyles = (status: string) => {
const getStatusColor = (status: string) => {
switch (status) {
case 'running':
return {
badge: 'bg-green-500/20 text-green-400 border-green-500/30',
card: 'border-green-500/30 hover:border-green-500/50',
glow: 'shadow-green-500/10'
};
case 'paused':
return {
badge: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
card: 'border-orange-500/30 hover:border-orange-500/50',
glow: 'shadow-orange-500/10'
};
case 'completed':
return {
badge: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
card: 'border-blue-500/30 hover:border-blue-500/50',
glow: 'shadow-blue-500/10'
};
case 'not_started':
return {
badge: 'bg-dark-600 text-dark-400 border-dark-500',
card: 'border-dark-600 hover:border-dark-500',
glow: ''
};
default:
return {
badge: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
card: 'border-yellow-500/30 hover:border-yellow-500/50',
glow: 'shadow-yellow-500/10'
};
case 'running': return 'text-green-400 bg-green-500/10';
case 'paused': return 'text-orange-400 bg-orange-500/10';
case 'completed': return 'text-blue-400 bg-blue-500/10';
default: return 'text-dark-400 bg-dark-600';
}
};
// Study sort options
const [studySort, setStudySort] = useState<'date' | 'running' | 'trials'>('date');
// Sort studies based on selected sort option
const sortedStudies = [...studies].sort((a, b) => {
if (studySort === 'running') {
// Running first, then by date
if (a.status === 'running' && b.status !== 'running') return -1;
if (b.status === 'running' && a.status !== 'running') return 1;
}
if (studySort === 'trials') {
// By trial count (most trials first)
return b.progress.current - a.progress.current;
}
// Default: sort by date (newest first)
const aDate = a.last_modified || a.created_at || '';
const bDate = b.last_modified || b.created_at || '';
return bDate.localeCompare(aDate);
});
const displayedStudies = showAllStudies ? sortedStudies : sortedStudies.slice(0, 6);
return (
<div className="min-h-screen bg-dark-900">
{/* Header */}
@@ -162,352 +167,254 @@ const Home: React.FC = () => {
</header>
<main className="max-w-[1920px] mx-auto px-6 py-8">
{/* Study Selection Section */}
<section className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<FolderOpen className="w-5 h-5 text-primary-400" />
Select a Study
</h2>
<div className="flex items-center gap-4">
{/* Sort Controls */}
<div className="flex items-center gap-2">
<span className="text-sm text-dark-400">Sort:</span>
<div className="flex rounded-lg overflow-hidden border border-dark-600">
<button
onClick={() => setStudySort('date')}
className={`px-3 py-1.5 text-sm transition-colors ${
studySort === 'date'
? 'bg-primary-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
}`}
>
Newest
</button>
<button
onClick={() => setStudySort('running')}
className={`px-3 py-1.5 text-sm transition-colors ${
studySort === 'running'
? 'bg-primary-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
}`}
>
Running
</button>
<button
onClick={() => setStudySort('trials')}
className={`px-3 py-1.5 text-sm transition-colors ${
studySort === 'trials'
? 'bg-primary-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
}`}
>
Most Trials
</button>
</div>
</div>
{studies.length > 6 && (
<button
onClick={() => setShowAllStudies(!showAllStudies)}
className="text-sm text-primary-400 hover:text-primary-300 flex items-center gap-1"
>
{showAllStudies ? (
<>Show Less <ChevronUp className="w-4 h-4" /></>
) : (
<>Show All ({studies.length}) <ChevronDown className="w-4 h-4" /></>
)}
</button>
)}
{/* Aggregate Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="bg-dark-800 rounded-xl p-4 border border-dark-600">
<div className="flex items-center gap-2 text-dark-400 text-sm mb-2">
<BarChart3 className="w-4 h-4" />
Total Studies
</div>
<div className="text-3xl font-bold text-white">{aggregateStats.totalStudies}</div>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12 text-dark-400">
<RefreshCw className="w-6 h-6 animate-spin mr-3" />
Loading studies...
<div className="bg-dark-800 rounded-xl p-4 border border-dark-600">
<div className="flex items-center gap-2 text-green-400 text-sm mb-2">
<Play className="w-4 h-4" />
Running
</div>
) : studies.length === 0 ? (
<div className="text-center py-12 text-dark-400">
<FolderOpen className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No studies found. Create a new study to get started.</p>
<div className="text-3xl font-bold text-green-400">{aggregateStats.runningStudies}</div>
</div>
<div className="bg-dark-800 rounded-xl p-4 border border-dark-600">
<div className="flex items-center gap-2 text-dark-400 text-sm mb-2">
<Activity className="w-4 h-4" />
Total Trials
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{displayedStudies.map((study) => {
const styles = getStatusStyles(study.status);
const isSelected = selectedPreview?.id === study.id;
<div className="text-3xl font-bold text-white">{aggregateStats.totalTrials.toLocaleString()}</div>
</div>
return (
<div
key={study.id}
onClick={() => setSelectedPreview(study)}
className={`
relative p-4 rounded-xl border cursor-pointer transition-all duration-200
bg-dark-800 hover:bg-dark-750
${styles.card} ${styles.glow}
${isSelected ? 'ring-2 ring-primary-500 border-primary-500' : ''}
`}
>
{/* Status Badge */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0 pr-2">
<h3 className="text-white font-medium truncate">{study.name || study.id}</h3>
<p className="text-dark-500 text-xs truncate mt-0.5">{study.id}</p>
</div>
<span className={`flex items-center gap-1.5 px-2 py-1 text-xs font-medium rounded-full border ${styles.badge}`}>
{getStatusIcon(study.status)}
{study.status}
</span>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm mb-3">
<div className="flex items-center gap-1.5 text-dark-400">
<Activity className="w-3.5 h-3.5" />
<span>{study.progress.current} trials</span>
</div>
{study.best_value !== null && (
<div className="flex items-center gap-1.5 text-primary-400">
<Target className="w-3.5 h-3.5" />
<span>{study.best_value.toFixed(4)}</span>
</div>
)}
</div>
{/* Progress Bar */}
<div className="h-1.5 bg-dark-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
study.status === 'running' ? 'bg-green-500' :
study.status === 'completed' ? 'bg-blue-500' : 'bg-primary-500'
}`}
style={{ width: `${Math.min((study.progress.current / study.progress.total) * 100, 100)}%` }}
/>
</div>
{/* Selected Indicator */}
{isSelected && (
<div className="absolute -bottom-px left-1/2 -translate-x-1/2 w-12 h-1 bg-primary-500 rounded-t-full" />
)}
</div>
);
})}
<div className="bg-dark-800 rounded-xl p-4 border border-dark-600">
<div className="flex items-center gap-2 text-primary-400 text-sm mb-2">
<Target className="w-4 h-4" />
Best Overall
</div>
)}
</section>
{/* Study Documentation Section */}
{selectedPreview && (
<section className="animate-in fade-in slide-in-from-bottom-4 duration-300">
{/* Documentation Header */}
<div className="bg-dark-800 rounded-t-xl border border-dark-600 border-b-0">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-dark-700 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5 text-primary-400" />
</div>
<div>
<h2 className="text-xl font-semibold text-white">{selectedPreview.name || selectedPreview.id}</h2>
<p className="text-dark-400 text-sm">Study Documentation</p>
</div>
</div>
<button
onClick={() => handleSelectStudy(selectedPreview)}
className="flex items-center gap-2 px-5 py-2.5 bg-primary-600 hover:bg-primary-500
text-white rounded-lg transition-all font-medium shadow-lg shadow-primary-500/20
hover:shadow-primary-500/30"
>
Open Dashboard
<ArrowRight className="w-4 h-4" />
</button>
<div className="text-2xl font-bold text-primary-400">
{aggregateStats.bestOverall?.best_value !== null && aggregateStats.bestOverall?.best_value !== undefined
? aggregateStats.bestOverall.best_value.toExponential(3)
: 'N/A'}
</div>
{aggregateStats.bestOverall && (
<div className="text-xs text-dark-400 mt-1 truncate">
{aggregateStats.bestOverall.name || aggregateStats.bestOverall.id}
</div>
)}
</div>
</div>
{/* Two-column layout: Table + Preview */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Study Table */}
<div className="bg-dark-800 rounded-xl border border-dark-600 overflow-hidden">
<div className="px-6 py-4 border-b border-dark-600 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-primary-400" />
Studies
</h2>
<span className="text-sm text-dark-400">{studies.length} studies</span>
</div>
{/* README Content */}
<div className="bg-dark-850 rounded-b-xl border border-dark-600 border-t-0 overflow-hidden">
{readmeLoading ? (
<div className="flex items-center justify-center py-16 text-dark-400">
<RefreshCw className="w-6 h-6 animate-spin mr-3" />
Loading documentation...
</div>
) : (
<div className="p-8 overflow-x-auto">
<article className="markdown-body max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[[rehypeKatex, { strict: false, trust: true, output: 'html' }]]}
components={{
// Custom heading styles
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-white mb-6 pb-3 border-b border-dark-600">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-semibold text-white mt-10 mb-4 pb-2 border-b border-dark-700">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-semibold text-white mt-8 mb-3">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-medium text-white mt-6 mb-2">
{children}
</h4>
),
// Paragraphs
p: ({ children }) => (
<p className="text-dark-300 leading-relaxed mb-4">
{children}
</p>
),
// Strong/Bold
strong: ({ children }) => (
<strong className="text-white font-semibold">{children}</strong>
),
// Links
a: ({ href, children }) => (
<a
href={href}
className="text-primary-400 hover:text-primary-300 underline underline-offset-2"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
// Lists
ul: ({ children }) => (
<ul className="list-disc list-inside text-dark-300 mb-4 space-y-1.5 ml-2">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside text-dark-300 mb-4 space-y-1.5 ml-2">
{children}
</ol>
),
li: ({ children }) => (
<li className="text-dark-300 leading-relaxed">{children}</li>
),
// Code blocks with syntax highlighting
code: ({ inline, className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
{isLoading ? (
<div className="flex items-center justify-center py-16 text-dark-400">
<RefreshCw className="w-6 h-6 animate-spin mr-3" />
Loading studies...
</div>
) : studies.length === 0 ? (
<div className="text-center py-16 text-dark-400">
<BarChart3 className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No studies found</p>
<p className="text-sm mt-1 text-dark-500">Create a new study to get started</p>
</div>
) : (
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
<table className="w-full">
<thead className="sticky top-0 bg-dark-750 z-10">
<tr className="border-b border-dark-600">
<th
className="text-left py-3 px-4 text-dark-400 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => handleSort('name')}
>
<div className="flex items-center gap-1">
Study Name
{sortField === 'name' && (
sortDir === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
)}
</div>
</th>
<th
className="text-left py-3 px-4 text-dark-400 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => handleSort('status')}
>
<div className="flex items-center gap-1">
Status
{sortField === 'status' && (
sortDir === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
)}
</div>
</th>
<th
className="text-left py-3 px-4 text-dark-400 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => handleSort('trials')}
>
<div className="flex items-center gap-1">
Progress
{sortField === 'trials' && (
sortDir === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
)}
</div>
</th>
<th
className="text-left py-3 px-4 text-dark-400 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => handleSort('bestValue')}
>
<div className="flex items-center gap-1">
Best
{sortField === 'bestValue' && (
sortDir === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
)}
</div>
</th>
</tr>
</thead>
<tbody>
{sortedStudies.map((study) => {
const completionPercent = study.progress.total > 0
? Math.round((study.progress.current / study.progress.total) * 100)
: 0;
if (!inline && language) {
return (
<div className="my-4 rounded-lg overflow-hidden border border-dark-600">
<div className="bg-dark-700 px-4 py-2 text-xs text-dark-400 font-mono border-b border-dark-600">
{language}
</div>
<SyntaxHighlighter
style={oneDark}
language={language}
PreTag="div"
customStyle={{
margin: 0,
padding: '1rem',
background: '#1a1d23',
fontSize: '0.875rem',
}}
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
);
}
if (!inline) {
return (
<pre className="my-4 p-4 bg-dark-700 rounded-lg border border-dark-600 overflow-x-auto">
<code className="text-primary-400 text-sm font-mono">{children}</code>
</pre>
);
}
return (
<code className="px-1.5 py-0.5 bg-dark-700 text-primary-400 rounded text-sm font-mono">
{children}
</code>
);
},
// Tables
table: ({ children }) => (
<div className="my-6 overflow-x-auto rounded-lg border border-dark-600">
<table className="w-full text-sm">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-dark-700 text-white">
{children}
</thead>
),
tbody: ({ children }) => (
<tbody className="divide-y divide-dark-600">
{children}
</tbody>
),
tr: ({ children }) => (
<tr className="hover:bg-dark-750 transition-colors">
{children}
</tr>
),
th: ({ children }) => (
<th className="px-4 py-3 text-left font-semibold text-white border-b border-dark-600">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-3 text-dark-300">
{children}
return (
<tr
key={study.id}
onClick={() => setSelectedPreview(study)}
className={`border-b border-dark-700 hover:bg-dark-750 transition-colors cursor-pointer ${
selectedPreview?.id === study.id ? 'bg-primary-900/20' : ''
}`}
>
<td className="py-3 px-4">
<div className="flex flex-col">
<span className="text-white font-medium truncate max-w-[200px]">
{study.name || study.id}
</span>
{study.name && (
<span className="text-xs text-dark-500 truncate max-w-[200px]">{study.id}</span>
)}
</div>
</td>
),
// Blockquotes
blockquote: ({ children }) => (
<blockquote className="my-4 pl-4 border-l-4 border-primary-500 bg-dark-750 py-3 pr-4 rounded-r-lg">
{children}
</blockquote>
),
// Horizontal rules
hr: () => (
<hr className="my-8 border-dark-600" />
),
// Images
img: ({ src, alt }) => (
<img
src={src}
alt={alt}
className="my-4 rounded-lg max-w-full h-auto border border-dark-600"
/>
),
}}
>
{readme}
</ReactMarkdown>
</article>
</div>
)}
</div>
</section>
)}
<td className="py-3 px-4">
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(study.status)}`}>
{getStatusIcon(study.status)}
{study.status}
</span>
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-dark-600 rounded-full overflow-hidden max-w-[80px]">
<div
className={`h-full transition-all ${
completionPercent >= 100 ? 'bg-green-500' :
completionPercent >= 50 ? 'bg-primary-500' :
'bg-yellow-500'
}`}
style={{ width: `${Math.min(completionPercent, 100)}%` }}
/>
</div>
<span className="text-dark-400 text-sm font-mono w-16">
{study.progress.current}/{study.progress.total}
</span>
</div>
</td>
<td className="py-3 px-4">
<span className={`font-mono text-sm ${study.best_value !== null ? 'text-primary-400' : 'text-dark-500'}`}>
{study.best_value !== null ? study.best_value.toExponential(3) : 'N/A'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Empty State when no study selected */}
{!selectedPreview && studies.length > 0 && (
<section className="flex items-center justify-center py-16 text-dark-400">
<div className="text-center">
<FileText className="w-16 h-16 mx-auto mb-4 opacity-30" />
<p className="text-lg">Select a study to view its documentation</p>
<p className="text-sm mt-1 text-dark-500">Click on any study card above</p>
</div>
</section>
)}
{/* Study Preview */}
<div className="bg-dark-800 rounded-xl border border-dark-600 overflow-hidden flex flex-col">
{selectedPreview ? (
<>
{/* Preview Header */}
<div className="px-6 py-4 border-b border-dark-600 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-dark-700 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5 text-primary-400" />
</div>
<div className="min-w-0">
<h2 className="text-lg font-semibold text-white truncate">
{selectedPreview.name || selectedPreview.id}
</h2>
<p className="text-dark-400 text-sm">Study Documentation</p>
</div>
</div>
<button
onClick={() => handleSelectStudy(selectedPreview)}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-500
text-white rounded-lg transition-all font-medium shadow-lg shadow-primary-500/20
hover:shadow-primary-500/30 whitespace-nowrap"
>
Open
<ArrowRight className="w-4 h-4" />
</button>
</div>
{/* Study Quick Stats */}
<div className="px-6 py-3 border-b border-dark-600 flex items-center gap-6 text-sm">
<div className="flex items-center gap-2">
{getStatusIcon(selectedPreview.status)}
<span className="text-dark-300 capitalize">{selectedPreview.status}</span>
</div>
<div className="flex items-center gap-2 text-dark-400">
<Activity className="w-4 h-4" />
<span>{selectedPreview.progress.current} / {selectedPreview.progress.total} trials</span>
</div>
{selectedPreview.best_value !== null && (
<div className="flex items-center gap-2 text-primary-400">
<Target className="w-4 h-4" />
<span>Best: {selectedPreview.best_value.toExponential(4)}</span>
</div>
)}
</div>
{/* README Content */}
<div className="flex-1 overflow-y-auto p-6">
{readmeLoading ? (
<div className="flex items-center justify-center py-16 text-dark-400">
<RefreshCw className="w-6 h-6 animate-spin mr-3" />
Loading documentation...
</div>
) : (
<MarkdownRenderer content={readme} />
)}
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-dark-400">
<div className="text-center">
<FileText className="w-16 h-16 mx-auto mb-4 opacity-30" />
<p className="text-lg">Select a study to preview</p>
<p className="text-sm mt-1 text-dark-500">Click on any row in the table</p>
</div>
</div>
)}
</div>
</div>
</main>
</div>
);

View File

@@ -10,14 +10,45 @@ import {
Loader2,
AlertTriangle,
CheckCircle,
Copy
Copy,
Trophy,
TrendingUp,
FileJson,
FileSpreadsheet,
Settings,
ArrowRight,
ChevronDown,
ChevronUp,
Printer
} from 'lucide-react';
import { apiClient } from '../api/client';
import { useStudy } from '../context/StudyContext';
import ReactMarkdown from 'react-markdown';
import { MarkdownRenderer } from '../components/MarkdownRenderer';
interface BestSolution {
best_trial: {
trial_number: number;
objective: number;
design_variables: Record<string, number>;
user_attrs?: Record<string, any>;
timestamp?: string;
} | null;
first_trial: {
trial_number: number;
objective: number;
design_variables: Record<string, number>;
} | null;
improvements: Record<string, {
initial: number;
final: number;
improvement_pct: number;
absolute_change: number;
}>;
total_trials: number;
}
export default function Results() {
const { selectedStudy } = useStudy();
const { selectedStudy, isInitialized } = useStudy();
const navigate = useNavigate();
const [reportContent, setReportContent] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -25,21 +56,37 @@ export default function Results() {
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [lastGenerated, setLastGenerated] = useState<string | null>(null);
const [bestSolution, setBestSolution] = useState<BestSolution | null>(null);
const [showAllParams, setShowAllParams] = useState(false);
const [exporting, setExporting] = useState<string | null>(null);
// Redirect if no study selected
// Redirect if no study selected (but only after initialization completes)
useEffect(() => {
if (!selectedStudy) {
if (isInitialized && !selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate]);
}, [selectedStudy, navigate, isInitialized]);
// Load report when study changes
// Load report and best solution when study changes
useEffect(() => {
if (selectedStudy) {
loadReport();
loadBestSolution();
}
}, [selectedStudy]);
// Show loading state while initializing (must be after all hooks)
if (!isInitialized) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-dark-400">Loading study...</p>
</div>
</div>
);
}
const loadReport = async () => {
if (!selectedStudy) return;
@@ -52,7 +99,7 @@ export default function Results() {
if (data.generated_at) {
setLastGenerated(data.generated_at);
}
} catch (err: any) {
} catch {
// No report yet - show placeholder
setReportContent(null);
} finally {
@@ -60,6 +107,17 @@ export default function Results() {
}
};
const loadBestSolution = async () => {
if (!selectedStudy) return;
try {
const data = await apiClient.getBestSolution(selectedStudy.id);
setBestSolution(data);
} catch {
setBestSolution(null);
}
};
const handleGenerate = async () => {
if (!selectedStudy) return;
@@ -101,17 +159,148 @@ export default function Results() {
URL.revokeObjectURL(url);
};
const handlePrintPDF = () => {
if (!reportContent || !selectedStudy) return;
// Create a printable version of the report
const printWindow = window.open('', '_blank');
if (!printWindow) {
setError('Pop-up blocked. Please allow pop-ups to print PDF.');
return;
}
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>${selectedStudy.name} - Optimization Report</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 40px;
color: #1a1a1a;
line-height: 1.6;
}
h1 { color: #2563eb; border-bottom: 2px solid #2563eb; padding-bottom: 10px; }
h2 { color: #1e40af; margin-top: 30px; }
h3 { color: #3730a3; }
table { border-collapse: collapse; width: 100%; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f3f4f6; font-weight: 600; }
tr:nth-child(even) { background: #f9fafb; }
code { background: #f3f4f6; padding: 2px 6px; border-radius: 4px; font-family: 'Monaco', monospace; }
pre { background: #1e1e1e; color: #d4d4d4; padding: 16px; border-radius: 8px; overflow-x: auto; }
pre code { background: transparent; padding: 0; }
blockquote { border-left: 4px solid #2563eb; margin: 20px 0; padding: 10px 20px; background: #eff6ff; }
.header-info { color: #666; margin-bottom: 30px; }
@media print {
body { padding: 20px; }
pre { white-space: pre-wrap; word-wrap: break-word; }
}
</style>
</head>
<body>
<div class="header-info">
<strong>Study:</strong> ${selectedStudy.name}<br>
<strong>Generated:</strong> ${new Date().toLocaleString()}<br>
<strong>Trials:</strong> ${selectedStudy.progress.current} / ${selectedStudy.progress.total}
</div>
${convertMarkdownToHTML(reportContent)}
</body>
</html>
`);
printWindow.document.close();
// Wait for content to load then print
printWindow.onload = () => {
printWindow.print();
};
};
// Simple markdown to HTML converter for print
const convertMarkdownToHTML = (md: string): string => {
return md
// Headers
.replace(/^### (.*$)/gm, '<h3>$1</h3>')
.replace(/^## (.*$)/gm, '<h2>$1</h2>')
.replace(/^# (.*$)/gm, '<h1>$1</h1>')
// Bold and italic
.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
// Code blocks
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Lists
.replace(/^\s*[-*]\s+(.*)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)\n(?!<li>)/g, '</ul>$1\n')
.replace(/(?<!<\/ul>)(<li>)/g, '<ul>$1')
// Blockquotes
.replace(/^>\s*(.*)$/gm, '<blockquote>$1</blockquote>')
// Horizontal rules
.replace(/^---$/gm, '<hr>')
// Paragraphs
.replace(/\n\n/g, '</p><p>')
.replace(/^(.+)$/gm, (match) => {
if (match.startsWith('<')) return match;
return match;
});
};
const handleExport = async (format: 'csv' | 'json' | 'config') => {
if (!selectedStudy) return;
setExporting(format);
try {
const data = await apiClient.exportData(selectedStudy.id, format);
if (data.filename && data.content) {
const blob = new Blob([data.content], { type: data.content_type || 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = data.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else if (format === 'json' && data.trials) {
// Direct JSON response
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedStudy.id}_data.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
} catch (err: any) {
setError(err.message || `Failed to export ${format}`);
} finally {
setExporting(null);
}
};
if (!selectedStudy) {
return null;
}
const paramEntries = bestSolution?.best_trial?.design_variables
? Object.entries(bestSolution.best_trial.design_variables)
: [];
const visibleParams = showAllParams ? paramEntries : paramEntries.slice(0, 6);
return (
<div className="h-full flex flex-col">
<div className="h-full flex flex-col max-w-[2400px] mx-auto px-4">
{/* Header */}
<header className="mb-6 flex items-center justify-between">
<header className="mb-6 flex items-center justify-between border-b border-dark-600 pb-4">
<div>
<h1 className="text-2xl font-bold text-white">Optimization Report</h1>
<p className="text-dark-400 mt-1">{selectedStudy.name}</p>
<h1 className="text-2xl font-bold text-primary-400">Results</h1>
<p className="text-dark-400 text-sm">{selectedStudy.name}</p>
</div>
<div className="flex gap-2">
<Button
@@ -153,7 +342,156 @@ export default function Results() {
</div>
)}
{/* Main Content */}
{/* Best Solution Card */}
{bestSolution?.best_trial && (
<Card className="mb-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center">
<Trophy className="w-5 h-5 text-yellow-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Best Solution</h2>
<p className="text-sm text-dark-400">Trial #{bestSolution.best_trial.trial_number} of {bestSolution.total_trials}</p>
</div>
{bestSolution.improvements.objective && (
<div className="ml-auto flex items-center gap-2 px-4 py-2 bg-green-900/20 rounded-lg border border-green-800/30">
<TrendingUp className="w-5 h-5 text-green-400" />
<span className="text-green-400 font-bold text-lg">
{bestSolution.improvements.objective.improvement_pct > 0 ? '+' : ''}
{bestSolution.improvements.objective.improvement_pct.toFixed(1)}%
</span>
<span className="text-dark-400 text-sm">improvement</span>
</div>
)}
</div>
{/* Objective Value */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="bg-dark-700 rounded-lg p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Best Objective</div>
<div className="text-2xl font-bold text-primary-400">
{bestSolution.best_trial.objective.toExponential(4)}
</div>
</div>
{bestSolution.first_trial && (
<div className="bg-dark-700 rounded-lg p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Initial Value</div>
<div className="text-2xl font-bold text-dark-300">
{bestSolution.first_trial.objective.toExponential(4)}
</div>
</div>
)}
{bestSolution.improvements.objective && (
<div className="bg-dark-700 rounded-lg p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Absolute Change</div>
<div className="text-2xl font-bold text-green-400 flex items-center gap-2">
<ArrowRight className="w-5 h-5" />
{bestSolution.improvements.objective.absolute_change.toExponential(4)}
</div>
</div>
)}
</div>
{/* Design Variables */}
<div className="border-t border-dark-600 pt-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-dark-300">Optimal Design Variables</h3>
{paramEntries.length > 6 && (
<button
onClick={() => setShowAllParams(!showAllParams)}
className="text-xs text-primary-400 hover:text-primary-300 flex items-center gap-1"
>
{showAllParams ? (
<>Show Less <ChevronUp className="w-3 h-3" /></>
) : (
<>Show All ({paramEntries.length}) <ChevronDown className="w-3 h-3" /></>
)}
</button>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{visibleParams.map(([name, value]) => (
<div key={name} className="bg-dark-800 rounded px-3 py-2">
<div className="text-xs text-dark-400 truncate" title={name}>{name}</div>
<div className="text-sm font-mono text-white">
{typeof value === 'number' ? value.toFixed(4) : value}
</div>
</div>
))}
</div>
</div>
</Card>
)}
{/* Export Options */}
<Card className="mb-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Download className="w-5 h-5 text-primary-400" />
Export Data
</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<button
onClick={() => handleExport('csv')}
disabled={exporting !== null}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50"
>
<FileSpreadsheet className="w-8 h-8 text-green-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">CSV</div>
<div className="text-xs text-dark-400">Spreadsheet</div>
</div>
{exporting === 'csv' && <Loader2 className="w-4 h-4 animate-spin ml-auto" />}
</button>
<button
onClick={() => handleExport('json')}
disabled={exporting !== null}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50"
>
<FileJson className="w-8 h-8 text-blue-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">JSON</div>
<div className="text-xs text-dark-400">Full data</div>
</div>
{exporting === 'json' && <Loader2 className="w-4 h-4 animate-spin ml-auto" />}
</button>
<button
onClick={() => handleExport('config')}
disabled={exporting !== null}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50"
>
<Settings className="w-8 h-8 text-purple-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">Config</div>
<div className="text-xs text-dark-400">Settings</div>
</div>
{exporting === 'config' && <Loader2 className="w-4 h-4 animate-spin ml-auto" />}
</button>
<button
onClick={handleDownload}
disabled={!reportContent}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<FileText className="w-8 h-8 text-orange-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">Report</div>
<div className="text-xs text-dark-400">Markdown</div>
</div>
</button>
<button
onClick={handlePrintPDF}
disabled={!reportContent}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Printer className="w-8 h-8 text-red-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">PDF</div>
<div className="text-xs text-dark-400">Print report</div>
</div>
</button>
</div>
</Card>
{/* Main Content - Report */}
<div className="flex-1 min-h-0">
<Card className="h-full overflow-hidden flex flex-col">
<div className="flex items-center justify-between border-b border-dark-600 pb-4 mb-4">
@@ -175,18 +513,8 @@ export default function Results() {
<span>Loading report...</span>
</div>
) : reportContent ? (
<div className="prose prose-invert prose-sm max-w-none
prose-headings:text-white prose-headings:font-semibold
prose-p:text-dark-300 prose-strong:text-white
prose-code:text-primary-400 prose-code:bg-dark-700 prose-code:px-1 prose-code:rounded
prose-pre:bg-dark-700 prose-pre:border prose-pre:border-dark-600
prose-a:text-primary-400 prose-a:no-underline hover:prose-a:underline
prose-ul:text-dark-300 prose-ol:text-dark-300
prose-li:text-dark-300
prose-table:border-collapse prose-th:border prose-th:border-dark-600 prose-th:p-2 prose-th:bg-dark-700
prose-td:border prose-td:border-dark-600 prose-td:p-2
prose-hr:border-dark-600">
<ReactMarkdown>{reportContent}</ReactMarkdown>
<div className="p-2">
<MarkdownRenderer content={reportContent} />
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-dark-400">

View File

@@ -0,0 +1,780 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Settings,
Target,
Sliders,
AlertTriangle,
Cpu,
Box,
Layers,
Play,
Download,
RefreshCw,
ChevronDown,
ChevronUp,
ArrowUp,
ArrowDown,
CheckCircle,
Info,
FileBox,
FolderOpen,
File
} from 'lucide-react';
import { useStudy } from '../context/StudyContext';
import { Card } from '../components/common/Card';
import { Button } from '../components/common/Button';
import { apiClient, ModelFile } from '../api/client';
interface StudyConfig {
study_name: string;
description?: string;
objectives: {
name: string;
direction: 'minimize' | 'maximize';
unit?: string;
target?: number;
weight?: number;
}[];
design_variables: {
name: string;
type: 'float' | 'int' | 'categorical';
low?: number;
high?: number;
step?: number;
choices?: string[];
unit?: string;
}[];
constraints: {
name: string;
type: 'le' | 'ge' | 'eq';
bound: number;
unit?: string;
}[];
algorithm: {
name: string;
sampler: string;
pruner?: string;
n_trials: number;
timeout?: number;
};
fea_model?: {
software: string;
solver: string;
sim_file?: string;
mesh_elements?: number;
};
extractors?: {
name: string;
type: string;
source?: string;
}[];
}
export default function Setup() {
const navigate = useNavigate();
const { selectedStudy, isInitialized } = useStudy();
const [config, setConfig] = useState<StudyConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['objectives', 'variables', 'constraints', 'algorithm', 'modelFiles'])
);
const [modelFiles, setModelFiles] = useState<ModelFile[]>([]);
const [modelDir, setModelDir] = useState<string>('');
// Redirect if no study selected
useEffect(() => {
if (isInitialized && !selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate, isInitialized]);
// Load study configuration
useEffect(() => {
if (selectedStudy) {
loadConfig();
loadModelFiles();
}
}, [selectedStudy]);
const loadModelFiles = async () => {
if (!selectedStudy) return;
try {
const data = await apiClient.getModelFiles(selectedStudy.id);
setModelFiles(data.files);
setModelDir(data.model_dir);
} catch (err) {
console.error('Failed to load model files:', err);
}
};
const handleOpenFolder = async () => {
if (!selectedStudy) return;
try {
await apiClient.openFolder(selectedStudy.id, 'model');
} catch (err: any) {
setError(err.message || 'Failed to open folder');
}
};
const loadConfig = async () => {
if (!selectedStudy) return;
setLoading(true);
setError(null);
try {
const response = await apiClient.getStudyConfig(selectedStudy.id);
const rawConfig = response.config;
// Transform backend config format to our StudyConfig format
const transformedConfig: StudyConfig = {
study_name: rawConfig.study_name || selectedStudy.name || selectedStudy.id,
description: rawConfig.description,
objectives: (rawConfig.objectives || []).map((obj: any) => ({
name: obj.name,
direction: obj.direction || 'minimize',
unit: obj.unit || obj.units,
target: obj.target,
weight: obj.weight
})),
design_variables: (rawConfig.design_variables || []).map((dv: any) => ({
name: dv.name,
type: dv.type || 'float',
low: dv.min ?? dv.low,
high: dv.max ?? dv.high,
step: dv.step,
choices: dv.choices,
unit: dv.unit || dv.units
})),
constraints: (rawConfig.constraints || []).map((c: any) => ({
name: c.name,
type: c.type || 'le',
bound: c.max_value ?? c.min_value ?? c.bound ?? 0,
unit: c.unit || c.units
})),
algorithm: {
name: rawConfig.optimizer?.name || rawConfig.algorithm?.name || 'Optuna',
sampler: rawConfig.optimization_settings?.sampler || rawConfig.algorithm?.sampler || 'TPESampler',
pruner: rawConfig.optimization_settings?.pruner || rawConfig.algorithm?.pruner,
n_trials: rawConfig.optimization_settings?.n_trials || rawConfig.trials?.n_trials || selectedStudy.progress.total,
timeout: rawConfig.optimization_settings?.timeout
},
fea_model: rawConfig.fea_model || rawConfig.solver ? {
software: rawConfig.fea_model?.software || rawConfig.solver?.type || 'NX Nastran',
solver: rawConfig.fea_model?.solver || rawConfig.solver?.name || 'SOL 103',
sim_file: rawConfig.sim_file || rawConfig.fea_model?.sim_file,
mesh_elements: rawConfig.fea_model?.mesh_elements
} : undefined,
extractors: rawConfig.extractors
};
setConfig(transformedConfig);
} catch (err: any) {
// If no config endpoint, create mock from available data
setConfig({
study_name: selectedStudy.name || selectedStudy.id,
objectives: [{ name: 'objective', direction: 'minimize' }],
design_variables: [],
constraints: [],
algorithm: {
name: 'Optuna',
sampler: 'TPESampler',
n_trials: selectedStudy.progress.total
}
});
setError('Configuration loaded with limited data');
} finally {
setLoading(false);
}
};
const toggleSection = (section: string) => {
setExpandedSections(prev => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
};
const handleStartOptimization = async () => {
if (!selectedStudy) return;
try {
await apiClient.startOptimization(selectedStudy.id);
navigate('/dashboard');
} catch (err: any) {
setError(err.message || 'Failed to start optimization');
}
};
const handleExportConfig = () => {
if (!config) return;
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedStudy?.id || 'study'}_config.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Loading state
if (!isInitialized) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-dark-400">Loading study...</p>
</div>
</div>
);
}
if (!selectedStudy) return null;
// Calculate design space size
const designSpaceSize = config?.design_variables.reduce((acc, v) => {
if (v.type === 'categorical' && v.choices) {
return acc * v.choices.length;
} else if (v.type === 'int' && v.low !== undefined && v.high !== undefined) {
return acc * (v.high - v.low + 1);
}
return acc * 1000; // Approximate for continuous
}, 1) || 0;
return (
<div className="w-full max-w-[2400px] mx-auto px-4">
{/* Header */}
<header className="mb-6 flex items-center justify-between border-b border-dark-600 pb-4">
<div>
<div className="flex items-center gap-3">
<Settings className="w-8 h-8 text-primary-400" />
<div>
<h1 className="text-2xl font-bold text-white">{config?.study_name || selectedStudy.name}</h1>
<p className="text-dark-400 text-sm">Study Configuration</p>
</div>
</div>
{config?.description && (
<p className="text-dark-300 mt-2 max-w-2xl">{config.description}</p>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="secondary"
icon={<RefreshCw className="w-4 h-4" />}
onClick={loadConfig}
disabled={loading}
>
Refresh
</Button>
<Button
variant="secondary"
icon={<Download className="w-4 h-4" />}
onClick={handleExportConfig}
disabled={!config}
>
Export
</Button>
{selectedStudy.status === 'not_started' && (
<Button
variant="primary"
icon={<Play className="w-4 h-4" />}
onClick={handleStartOptimization}
>
Start Optimization
</Button>
)}
</div>
</header>
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-yellow-900/20 border border-yellow-800/30 rounded-lg">
<div className="flex items-center gap-2 text-yellow-400 text-sm">
<Info className="w-4 h-4" />
<span>{error}</span>
</div>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-16">
<RefreshCw className="w-8 h-8 animate-spin text-dark-400" />
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column */}
<div className="space-y-6">
{/* Objectives Panel */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('objectives')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Target className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Objectives</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{config?.objectives.length || 0}
</span>
</div>
{expandedSections.has('objectives') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('objectives') && (
<div className="px-4 pb-4 space-y-3">
{config?.objectives.map((obj, idx) => (
<div
key={idx}
className="bg-dark-750 rounded-lg p-4 border border-dark-600"
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-white">{obj.name}</span>
<span className={`flex items-center gap-1 text-sm px-2 py-1 rounded ${
obj.direction === 'minimize'
? 'bg-green-500/10 text-green-400'
: 'bg-blue-500/10 text-blue-400'
}`}>
{obj.direction === 'minimize' ? (
<ArrowDown className="w-3 h-3" />
) : (
<ArrowUp className="w-3 h-3" />
)}
{obj.direction}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-dark-400">
{obj.unit && <span>Unit: {obj.unit}</span>}
{obj.target !== undefined && (
<span>Target: {obj.target}</span>
)}
{obj.weight !== undefined && (
<span>Weight: {obj.weight}</span>
)}
</div>
</div>
))}
{config?.objectives.length === 0 && (
<p className="text-dark-500 text-sm italic">No objectives configured</p>
)}
<div className="text-xs text-dark-500 pt-2">
Type: {(config?.objectives.length || 0) > 1 ? 'Multi-Objective' : 'Single-Objective'}
</div>
</div>
)}
</Card>
{/* Design Variables Panel */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('variables')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Sliders className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Design Variables</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{config?.design_variables.length || 0}
</span>
</div>
{expandedSections.has('variables') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('variables') && (
<div className="px-4 pb-4 space-y-2">
{config?.design_variables.map((v, idx) => (
<div
key={idx}
className="bg-dark-750 rounded-lg p-3 border border-dark-600"
>
<div className="flex items-center justify-between">
<span className="font-medium text-white font-mono text-sm">{v.name}</span>
<span className="text-xs bg-dark-600 text-dark-400 px-2 py-0.5 rounded">
{v.type}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-dark-400 mt-1">
{v.low !== undefined && v.high !== undefined && (
<span>Range: [{v.low}, {v.high}]</span>
)}
{v.step && <span>Step: {v.step}</span>}
{v.unit && <span>{v.unit}</span>}
{v.choices && (
<span>Choices: {v.choices.join(', ')}</span>
)}
</div>
</div>
))}
{config?.design_variables.length === 0 && (
<p className="text-dark-500 text-sm italic">No design variables configured</p>
)}
{designSpaceSize > 0 && (
<div className="text-xs text-dark-500 pt-2">
Design Space: ~{designSpaceSize.toExponential(2)} combinations
</div>
)}
</div>
)}
</Card>
{/* Constraints Panel */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('constraints')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-yellow-400" />
<h2 className="text-lg font-semibold text-white">Constraints</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{config?.constraints.length || 0}
</span>
</div>
{expandedSections.has('constraints') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('constraints') && (
<div className="px-4 pb-4">
{(config?.constraints.length || 0) > 0 ? (
<table className="w-full text-sm">
<thead>
<tr className="text-dark-400 text-left">
<th className="pb-2">Name</th>
<th className="pb-2">Type</th>
<th className="pb-2">Bound</th>
<th className="pb-2">Unit</th>
</tr>
</thead>
<tbody className="text-dark-300">
{config?.constraints.map((c, idx) => (
<tr key={idx} className="border-t border-dark-700">
<td className="py-2 font-mono">{c.name}</td>
<td className="py-2">
{c.type === 'le' ? '≤' : c.type === 'ge' ? '≥' : '='}
</td>
<td className="py-2">{c.bound}</td>
<td className="py-2 text-dark-500">{c.unit || '-'}</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="text-dark-500 text-sm italic">No constraints configured</p>
)}
</div>
)}
</Card>
</div>
{/* Right Column */}
<div className="space-y-6">
{/* Algorithm Configuration */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('algorithm')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Cpu className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Algorithm Configuration</h2>
</div>
{expandedSections.has('algorithm') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('algorithm') && (
<div className="px-4 pb-4 space-y-3">
<div className="grid grid-cols-2 gap-4">
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Optimizer</div>
<div className="text-white font-medium">{config?.algorithm.name || 'Optuna'}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Sampler</div>
<div className="text-white font-medium">{config?.algorithm.sampler || 'TPE'}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Total Trials</div>
<div className="text-white font-medium">{config?.algorithm.n_trials || selectedStudy.progress.total}</div>
</div>
{config?.algorithm.pruner && (
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Pruner</div>
<div className="text-white font-medium">{config.algorithm.pruner}</div>
</div>
)}
{config?.algorithm.timeout && (
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Timeout</div>
<div className="text-white font-medium">{config.algorithm.timeout}s</div>
</div>
)}
</div>
</div>
)}
</Card>
{/* FEA Model Info */}
{config?.fea_model && (
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('model')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Box className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">FEA Model</h2>
</div>
{expandedSections.has('model') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('model') && (
<div className="px-4 pb-4 space-y-3">
<div className="grid grid-cols-2 gap-4">
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Software</div>
<div className="text-white font-medium">{config.fea_model.software}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Solver</div>
<div className="text-white font-medium">{config.fea_model.solver}</div>
</div>
{config.fea_model.mesh_elements && (
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Mesh Elements</div>
<div className="text-white font-medium">
{config.fea_model.mesh_elements.toLocaleString()}
</div>
</div>
)}
{config.fea_model.sim_file && (
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600 col-span-2">
<div className="text-xs text-dark-400 uppercase mb-1">Simulation File</div>
<div className="text-white font-mono text-sm truncate">
{config.fea_model.sim_file}
</div>
</div>
)}
</div>
</div>
)}
</Card>
)}
{/* NX Model Files */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('modelFiles')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<FileBox className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">NX Model Files</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{modelFiles.length}
</span>
</div>
{expandedSections.has('modelFiles') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('modelFiles') && (
<div className="px-4 pb-4 space-y-3">
{/* Open Folder Button */}
<button
onClick={handleOpenFolder}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-dark-700 hover:bg-dark-600 text-dark-200 hover:text-white rounded-lg border border-dark-600 transition-colors"
>
<FolderOpen className="w-4 h-4" />
<span>Open Model Folder</span>
</button>
{/* Model Directory Path */}
{modelDir && (
<div className="text-xs text-dark-500 font-mono truncate" title={modelDir}>
{modelDir}
</div>
)}
{/* File List */}
{modelFiles.length > 0 ? (
<div className="space-y-2">
{modelFiles.map((file, idx) => (
<div
key={idx}
className="bg-dark-750 rounded-lg p-3 border border-dark-600"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<File className={`w-4 h-4 ${
file.extension === '.prt' ? 'text-blue-400' :
file.extension === '.sim' ? 'text-green-400' :
file.extension === '.fem' ? 'text-yellow-400' :
file.extension === '.bdf' || file.extension === '.dat' ? 'text-orange-400' :
file.extension === '.op2' ? 'text-purple-400' :
'text-dark-400'
}`} />
<span className="font-medium text-white text-sm truncate" title={file.name}>
{file.name}
</span>
</div>
<span className="text-xs bg-dark-600 text-dark-400 px-2 py-0.5 rounded uppercase">
{file.extension.slice(1)}
</span>
</div>
<div className="flex items-center justify-between mt-1 text-xs text-dark-500">
<span>{file.size_display}</span>
<span>{new Date(file.modified).toLocaleDateString()}</span>
</div>
</div>
))}
</div>
) : (
<p className="text-dark-500 text-sm italic text-center py-4">
No model files found
</p>
)}
{/* File Type Legend */}
{modelFiles.length > 0 && (
<div className="pt-2 border-t border-dark-700">
<div className="flex flex-wrap gap-3 text-xs text-dark-500">
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-blue-400 rounded-full"></span>.prt = Part</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-green-400 rounded-full"></span>.sim = Simulation</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-yellow-400 rounded-full"></span>.fem = FEM</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-orange-400 rounded-full"></span>.bdf = Nastran</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-purple-400 rounded-full"></span>.op2 = Results</span>
</div>
</div>
)}
</div>
)}
</Card>
{/* Extractors */}
{config?.extractors && config.extractors.length > 0 && (
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('extractors')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Layers className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Extractors</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{config.extractors.length}
</span>
</div>
{expandedSections.has('extractors') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('extractors') && (
<div className="px-4 pb-4 space-y-2">
{config.extractors.map((ext, idx) => (
<div
key={idx}
className="bg-dark-750 rounded-lg p-3 border border-dark-600"
>
<div className="flex items-center justify-between">
<span className="font-medium text-white">{ext.name}</span>
<span className="text-xs bg-dark-600 text-dark-400 px-2 py-0.5 rounded">
{ext.type}
</span>
</div>
{ext.source && (
<div className="text-xs text-dark-500 mt-1 font-mono">{ext.source}</div>
)}
</div>
))}
</div>
)}
</Card>
)}
{/* Study Stats */}
<Card title="Current Progress">
<div className="grid grid-cols-2 gap-4">
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600 text-center">
<div className="text-3xl font-bold text-white">
{selectedStudy.progress.current}
</div>
<div className="text-xs text-dark-400 uppercase mt-1">Trials Completed</div>
</div>
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600 text-center">
<div className="text-3xl font-bold text-primary-400">
{selectedStudy.best_value?.toExponential(3) || 'N/A'}
</div>
<div className="text-xs text-dark-400 uppercase mt-1">Best Value</div>
</div>
</div>
{/* Progress Bar */}
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-dark-400">Progress</span>
<span className="text-white">
{Math.round((selectedStudy.progress.current / selectedStudy.progress.total) * 100)}%
</span>
</div>
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full transition-all"
style={{
width: `${Math.min((selectedStudy.progress.current / selectedStudy.progress.total) * 100, 100)}%`
}}
/>
</div>
</div>
{/* Status Badge */}
<div className="mt-4 flex items-center justify-center">
<span className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium ${
selectedStudy.status === 'running' ? 'bg-green-500/10 text-green-400' :
selectedStudy.status === 'completed' ? 'bg-blue-500/10 text-blue-400' :
selectedStudy.status === 'paused' ? 'bg-orange-500/10 text-orange-400' :
'bg-dark-600 text-dark-400'
}`}>
{selectedStudy.status === 'completed' && <CheckCircle className="w-4 h-4" />}
{selectedStudy.status === 'running' && <Play className="w-4 h-4" />}
<span className="capitalize">{selectedStudy.status.replace('_', ' ')}</span>
</span>
</div>
</Card>
</div>
</div>
)}
</div>
);
}

View File

@@ -2,7 +2,7 @@
export interface Study {
id: string;
name: string;
status: 'not_started' | 'running' | 'completed';
status: 'not_started' | 'running' | 'paused' | 'completed';
progress: {
current: number;
total: number;
@@ -96,6 +96,14 @@ export interface ProgressMessage {
export interface TrialPrunedMessage extends PrunedTrial {}
// Objective type
export interface Objective {
name: string;
direction?: 'minimize' | 'maximize';
unit?: string;
weight?: number;
}
// Chart data types
export interface ConvergenceDataPoint {
trial_number: number;
@@ -114,7 +122,7 @@ export interface ParameterSpaceDataPoint {
// Study status types
export interface StudyStatus {
study_id: string;
status: 'not_started' | 'running' | 'completed';
status: 'not_started' | 'running' | 'paused' | 'completed';
progress: {
current: number;
total: number;