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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
458
atomizer-dashboard/frontend/src/components/ConfigEditor.tsx
Normal file
458
atomizer-dashboard/frontend/src/components/ConfigEditor.tsx
Normal 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;
|
||||
184
atomizer-dashboard/frontend/src/components/MarkdownRenderer.tsx
Normal file
184
atomizer-dashboard/frontend/src/components/MarkdownRenderer.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CurrentTrialPanel } from './CurrentTrialPanel';
|
||||
export { OptimizerStatePanel } from './OptimizerStatePanel';
|
||||
@@ -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}
|
||||
|
||||
172
atomizer-dashboard/frontend/src/hooks/useNotifications.ts
Normal file
172
atomizer-dashboard/frontend/src/hooks/useNotifications.ts
Normal 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;
|
||||
757
atomizer-dashboard/frontend/src/pages/Analysis.tsx
Normal file
757
atomizer-dashboard/frontend/src/pages/Analysis.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
780
atomizer-dashboard/frontend/src/pages/Setup.tsx
Normal file
780
atomizer-dashboard/frontend/src/pages/Setup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user