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:
@@ -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';
|
||||
Reference in New Issue
Block a user