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
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
@@ -239,22 +344,22 @@ export function PlotlyParetoPlot({
{/* Objective selectors */}
-
X:
+
X:
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) => (
{obj.name}
))}
-
Y:
+
Y:
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) => (
{obj.name}
@@ -263,11 +368,11 @@ export function PlotlyParetoPlot({
{viewMode === '3d' && objectives.length >= 3 && (
<>
- Z:
+ Z:
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) => (
{obj.name}
@@ -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 && (
+
+
+
+
+ Trial
+ {objectives[i]?.name || 'Obj 1'}
+ {objectives[j]?.name || 'Obj 2'}
+ Source
+
+
+
+ {sortedParetoTrials.slice(0, 10).map(trial => (
+
+ #{trial.trial_number}
+
+ {getObjValue(trial, i).toExponential(4)}
+
+
+ {getObjValue(trial, j).toExponential(4)}
+
+
+
+ {trial.source || trial.user_attrs?.source || 'FEA'}
+
+
+
+ ))}
+
+
+ {sortedParetoTrials.length > 10 && (
+
+ Showing 10 of {sortedParetoTrials.length} Pareto-optimal solutions
+
+ )}
+
+ )}
);
}
diff --git a/atomizer-dashboard/frontend/src/components/plotly/PlotlyRunComparison.tsx b/atomizer-dashboard/frontend/src/components/plotly/PlotlyRunComparison.tsx
new file mode 100644
index 00000000..9a6ec3df
--- /dev/null
+++ b/atomizer-dashboard/frontend/src/components/plotly/PlotlyRunComparison.tsx
@@ -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: '
%{x} Trials: %{y}
'
+ };
+
+ // 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: '
%{x} Best: %{y:.4e}
'
+ };
+
+ 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 (
+
+ No run data available
+
+ );
+ }
+
+ return (
+
+ {/* Stats Summary */}
+
+
+
Total Runs
+
{stats.totalRuns}
+
+
+
Total Trials
+
{stats.totalTrials}
+
+
+
FEA Trials
+
{stats.feaTrials}
+
+
+
NN Trials
+
{stats.nnTrials}
+
+
+
Best Value
+
+ {stats.overallBest !== null ? stats.overallBest.toExponential(3) : 'N/A'}
+
+
+
+
Improvement
+
+ {stats.improvement !== null ? (
+ <>
+ {stats.improvement > 0 ? :
+ stats.improvement < 0 ? :
+ }
+ {Math.abs(stats.improvement).toFixed(1)}%
+ >
+ ) : 'N/A'}
+
+
+
+
+ {/* Chart */}
+
+
+ {/* Runs Table */}
+
+
+
+
+ Run Name
+ Source
+ Trials
+ Best Value
+ Avg Value
+ Duration
+
+
+
+ {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 (
+
+ {run.name}
+
+
+ {run.source}
+
+
+ {run.trial_count}
+
+ {run.best_value !== null ? run.best_value.toExponential(4) : '-'}
+
+
+ {run.avg_value !== null ? run.avg_value.toExponential(4) : '-'}
+
+ {duration}
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+export default PlotlyRunComparison;
diff --git a/atomizer-dashboard/frontend/src/components/plotly/PlotlySurrogateQuality.tsx b/atomizer-dashboard/frontend/src/components/plotly/PlotlySurrogateQuality.tsx
new file mode 100644
index 00000000..e6aab4dc
--- /dev/null
+++ b/atomizer-dashboard/frontend/src/components/plotly/PlotlySurrogateQuality.tsx
@@ -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
;
+}
+
+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 (
+
+
No neural network evaluations in this study
+
+ );
+ }
+
+ // 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 (
+
+ {/* Source Distribution Over Time */}
+
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 */}
+
+
+ {/* FEA vs NN Best Values Comparison */}
+ {feaObjectives.length > 0 && nnObjectives.length > 0 && (
+
+
+
FEA Best
+
+ {Math.min(...feaObjectives).toExponential(4)}
+
+
+ from {feaObjectives.length} evaluations
+
+
+
+
NN Best
+
+ {Math.min(...nnObjectives).toExponential(4)}
+
+
+ from {nnObjectives.length} predictions
+
+
+
+ )}
+
+ );
+}
diff --git a/atomizer-dashboard/frontend/src/components/tracker/CurrentTrialPanel.tsx b/atomizer-dashboard/frontend/src/components/tracker/CurrentTrialPanel.tsx
new file mode 100644
index 00000000..b9db9abb
--- /dev/null
+++ b/atomizer-dashboard/frontend/src/components/tracker/CurrentTrialPanel.tsx
@@ -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('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 (
+
+ {/* Header Row */}
+
+
+
+
+ {isRunning ? `Trial #${completedTrials + 1}` : 'Optimization Status'}
+
+
+ {isRunning && (
+
+
+ {phaseInfo.label}
+
+ )}
+
+
+ {/* Progress Bar */}
+
+
+ Progress
+
+ {completedTrials} / {totalTrials} trials
+
+
+
+
+
+ {/* Stats Row */}
+
+ {/* Elapsed Time */}
+
+
+ {isRunning ? `${elapsedTime}s` : '--'}
+
+
Elapsed
+
+
+ {/* Completion */}
+
+
+ {progressPercent.toFixed(1)}%
+
+
Complete
+
+
+ {/* ETA */}
+
+
+ {eta || '--'}
+
+
ETA
+
+
+
+ {/* Running indicator */}
+ {isRunning && (
+
+
+
+ Optimization in progress...
+
+
+ )}
+
+ {/* Paused/Stopped indicator */}
+ {!isRunning && completedTrials > 0 && completedTrials < totalTrials && (
+
+
+
+ Optimization paused
+
+
+ )}
+
+ {/* Completed indicator */}
+ {!isRunning && completedTrials >= totalTrials && totalTrials > 0 && (
+
+
+
+ Optimization complete
+
+
+ )}
+
+ );
+}
diff --git a/atomizer-dashboard/frontend/src/components/tracker/OptimizerStatePanel.tsx b/atomizer-dashboard/frontend/src/components/tracker/OptimizerStatePanel.tsx
new file mode 100644
index 00000000..21fb41f1
--- /dev/null
+++ b/atomizer-dashboard/frontend/src/components/tracker/OptimizerStatePanel.tsx
@@ -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 = {
+ '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 (
+
+ {/* Header */}
+
+
+ Optimizer State
+
+
+ {/* Main Stats Grid */}
+
+ {/* Sampler */}
+
+
+
+ Sampler
+
+
+ {formatSampler(sampler)}
+
+
+
+ {/* Phase */}
+
+
+
+ Phase
+
+
+ {phase}
+
+
+
+
+ {/* FEA vs NN Trials (for hybrid optimizations) */}
+ {(feaTrials > 0 || nnTrials > 0) && (
+
+
Trial Sources
+
+
+
+
+
{nnTrials}
+
Neural Net
+
+
+ {nnTrials > 0 && (
+
+ {((nnTrials / (feaTrials + nnTrials)) * 100).toFixed(0)}% acceleration from surrogate
+
+ )}
+
+ )}
+
+ {/* Objectives */}
+ {objectives.length > 0 && (
+
+
+
+
+ {isMultiObjective ? 'Multi-Objective' : 'Single Objective'}
+
+
+
+ {objectives.slice(0, 3).map((obj, idx) => (
+
+
+ {obj.name.length > 20 ? obj.name.slice(0, 18) + '...' : obj.name}
+
+
+ {obj.direction === 'minimize' ? 'min' : 'max'}
+
+
+ ))}
+ {objectives.length > 3 && (
+
+ +{objectives.length - 3} more
+
+ )}
+
+
+ )}
+
+ {/* Pareto Front Size (for multi-objective) */}
+ {isMultiObjective && paretoSize > 0 && (
+
+
+ Pareto Front Size
+
+ {paretoSize} solutions
+
+
+
+ )}
+
+ );
+}
diff --git a/atomizer-dashboard/frontend/src/components/tracker/index.ts b/atomizer-dashboard/frontend/src/components/tracker/index.ts
new file mode 100644
index 00000000..e4a31bc0
--- /dev/null
+++ b/atomizer-dashboard/frontend/src/components/tracker/index.ts
@@ -0,0 +1,2 @@
+export { CurrentTrialPanel } from './CurrentTrialPanel';
+export { OptimizerStatePanel } from './OptimizerStatePanel';
diff --git a/atomizer-dashboard/frontend/src/context/StudyContext.tsx b/atomizer-dashboard/frontend/src/context/StudyContext.tsx
index 891e5bc4..9dd158b8 100644
--- a/atomizer-dashboard/frontend/src/context/StudyContext.tsx
+++ b/atomizer-dashboard/frontend/src/context/StudyContext.tsx
@@ -8,6 +8,7 @@ interface StudyContextType {
studies: Study[];
refreshStudies: () => Promise;
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(null);
const [studies, setStudies] = useState([]);
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}
diff --git a/atomizer-dashboard/frontend/src/hooks/useNotifications.ts b/atomizer-dashboard/frontend/src/hooks/useNotifications.ts
new file mode 100644
index 00000000..02c35853
--- /dev/null
+++ b/atomizer-dashboard/frontend/src/hooks/useNotifications.ts
@@ -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;
+ showNotification: (options: NotificationOptions) => void;
+ isEnabled: boolean;
+ setEnabled: (enabled: boolean) => void;
+}
+
+const STORAGE_KEY = 'atomizer-notifications-enabled';
+
+export function useNotifications(): UseNotificationsReturn {
+ const [permission, setPermission] = useState(
+ typeof Notification !== 'undefined' ? Notification.permission : 'unsupported'
+ );
+ const [isEnabled, setIsEnabledState] = useState(() => {
+ 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 => {
+ 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;
diff --git a/atomizer-dashboard/frontend/src/pages/Analysis.tsx b/atomizer-dashboard/frontend/src/pages/Analysis.tsx
new file mode 100644
index 00000000..4a317b54
--- /dev/null
+++ b/atomizer-dashboard/frontend/src/pages/Analysis.tsx
@@ -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 = () => (
+
+);
+
+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;
+ user_attrs?: Record;
+ 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('overview');
+ const [loading, setLoading] = useState(true);
+ const [trials, setTrials] = useState([]);
+ const [metadata, setMetadata] = useState(null);
+ const [paretoFront, setParetoFront] = useState([]);
+ const [runs, setRuns] = useState([]);
+
+ // 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 (
+
+ );
+ }
+
+ const isMultiObjective = (metadata?.objectives?.length || 0) > 1;
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Tab Navigation */}
+
+ {tabs.map(tab => (
+ !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.label}
+
+ ))}
+
+
+ {loading ? (
+
+
+
+ ) : (
+ <>
+ {/* Overview Tab */}
+ {activeTab === 'overview' && (
+
+ {/* Summary Stats */}
+ {stats && (
+
+
+ Total Trials
+ {stats.total}
+
+
+ Best Value
+ {stats.min.toExponential(3)}
+
+
+ Mean
+ {stats.mean.toExponential(3)}
+
+
+ Median
+ {stats.median.toExponential(3)}
+
+
+ Std Dev
+ {stats.stdDev.toExponential(3)}
+
+
+ Feasibility
+ {stats.feasibilityRate.toFixed(1)}%
+
+
+ )}
+
+ {/* Percentile Distribution */}
+ {stats && (
+
+
+
+
Min
+
{stats.min.toExponential(3)}
+
+
+
25th %
+
{stats.p25.toExponential(3)}
+
+
+
75th %
+
{stats.p75.toExponential(3)}
+
+
+
90th %
+
{stats.p90.toExponential(3)}
+
+
+
+ )}
+
+ {/* Convergence Plot */}
+ {trials.length > 0 && (
+
+ }>
+
+
+
+ )}
+
+ {/* Best Trials Table */}
+
+
+
+
+
+ Rank
+ Trial
+ Objective
+ Source
+ {Object.keys(trials[0]?.params || {}).slice(0, 3).map(p => (
+ {p}
+ ))}
+
+
+
+ {[...trials]
+ .sort((a, b) => (a.values[0] ?? Infinity) - (b.values[0] ?? Infinity))
+ .slice(0, 10)
+ .map((trial, idx) => (
+
+
+
+ {idx + 1}
+
+
+ #{trial.trial_number}
+ {trial.values[0]?.toExponential(4)}
+
+
+ {trial.source}
+
+
+ {Object.keys(trials[0]?.params || {}).slice(0, 3).map(p => (
+
+ {trial.params[p]?.toFixed(4)}
+
+ ))}
+
+ ))}
+
+
+
+
+
+ )}
+
+ {/* Parameters Tab */}
+ {activeTab === 'parameters' && (
+
+ {/* Parameter Importance */}
+ {trials.length > 0 && metadata?.design_variables && (
+
+ }>
+
+
+
+ )}
+
+ {/* Parallel Coordinates */}
+ {trials.length > 0 && metadata && (
+
+ }>
+
+
+
+ )}
+
+ )}
+
+ {/* Pareto Tab */}
+ {activeTab === 'pareto' && isMultiObjective && (
+
+ {/* Pareto Metrics */}
+
+
+ Pareto Solutions
+ {paretoFront.length}
+
+
+ Objectives
+ {metadata?.objectives?.length || 0}
+
+
+ Dominated Ratio
+
+ {trials.length > 0 ? ((1 - paretoFront.length / trials.length) * 100).toFixed(1) : 0}%
+
+
+
+
+ {/* Pareto Front Plot */}
+ {paretoFront.length > 0 && (
+
+ }>
+
+
+
+ )}
+
+ {/* Pareto Solutions Table */}
+
+
+
+
+
+ Trial
+ {metadata?.objectives?.map(obj => (
+ {obj.name}
+ ))}
+
+
+
+ {paretoFront.slice(0, 20).map((sol, idx) => (
+
+ #{sol.trial_number}
+ {sol.values?.map((v: number, i: number) => (
+ {v?.toExponential(4)}
+ ))}
+
+ ))}
+
+
+
+
+
+ )}
+
+ {/* Correlations Tab */}
+ {activeTab === 'correlations' && (
+
+ {/* Correlation Heatmap */}
+ {trials.length > 2 && (
+
+ }>
+
+
+
+ )}
+
+ {/* Correlation Interpretation Guide */}
+
+
+
+
Strong Positive (0.7 to 1.0)
+
Increasing parameter increases objective
+
+
+
Moderate Positive (0.3 to 0.7)
+
Some positive relationship
+
+
+
Moderate Negative (-0.7 to -0.3)
+
Some negative relationship
+
+
+
Strong Negative (-1.0 to -0.7)
+
Increasing parameter decreases objective
+
+
+
+
+ {/* Top Correlations Table */}
+ {trials.length > 2 && (
+
+
+
+ )}
+
+ )}
+
+ {/* Constraints Tab */}
+ {activeTab === 'constraints' && stats && (
+
+
+
+ Feasible Trials
+ {stats.feasible}
+
+
+ Infeasible Trials
+ {stats.total - stats.feasible}
+
+
+ Feasibility Rate
+ {stats.feasibilityRate.toFixed(1)}%
+
+
+
+ {/* Feasibility Over Time Chart */}
+
+ }>
+
+
+
+
+ {/* Infeasible Trials List */}
+ {stats.total - stats.feasible > 0 && (
+
+
+
+
+
+ Trial
+ Objective
+ Source
+
+
+
+ {trials
+ .filter(t => !t.constraint_satisfied)
+ .slice(-20)
+ .reverse()
+ .map(trial => (
+
+ #{trial.trial_number}
+ {trial.values[0]?.toExponential(4) || 'N/A'}
+
+
+ {trial.source}
+
+
+
+ ))}
+
+
+
+
+ )}
+
+ )}
+
+ {/* Surrogate Tab */}
+ {activeTab === 'surrogate' && stats && (
+
+
+
+ FEA Evaluations
+ {stats.feaTrials}
+
+
+ NN Predictions
+ {stats.nnTrials}
+
+
+ NN Ratio
+
+ {stats.nnTrials > 0 ? `${((stats.nnTrials / stats.total) * 100).toFixed(0)}%` : '0%'}
+
+
+
+ Speedup Factor
+
+ {stats.feaTrials > 0 ? `${(stats.total / stats.feaTrials).toFixed(1)}x` : '1.0x'}
+
+
+
+
+ {/* Surrogate Quality Charts */}
+
+ }>
+
+
+
+
+ )}
+
+ {/* Runs Tab */}
+ {activeTab === 'runs' && runs.length > 0 && (
+
+
+
+ Compare different optimization runs within this study. Studies with adaptive optimization
+ may have multiple runs (e.g., initial FEA exploration, NN-accelerated iterations).
+
+ }>
+
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
+
+// 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 Not enough data for correlation analysis
;
+ }
+
+ return (
+
+
+
+ Parameter
+ Correlation with {objectiveName}
+ Strength
+
+
+
+ {correlations.slice(0, 10).map(({ param, correlation, absCorr }) => (
+
+ {param}
+
+
+
+
0 ? 'bg-blue-500' : 'bg-red-500'}`}
+ style={{ width: `${absCorr * 100}%`, marginLeft: correlation < 0 ? 'auto' : 0 }}
+ />
+
+
0.7 ? 'text-white font-bold' :
+ absCorr > 0.3 ? 'text-dark-200' : 'text-dark-400'
+ }`}>
+ {correlation > 0 ? '+' : ''}{correlation.toFixed(3)}
+
+
+
+
+ 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'}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/atomizer-dashboard/frontend/src/pages/Dashboard.tsx b/atomizer-dashboard/frontend/src/pages/Dashboard.tsx
index 9097294b..2ae1f396 100644
--- a/atomizer-dashboard/frontend/src/pages/Dashboard.tsx
+++ b/atomizer-dashboard/frontend/src/pages/Dashboard.tsx
@@ -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([]);
const [displayedTrials, setDisplayedTrials] = useState([]);
const [bestValue, setBestValue] = useState(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(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(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(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 = (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 (
+
+ );
+ }
- // 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() {
Real-time optimization monitoring
+ {/* Config Editor Button */}
+ {selectedStudyId && (
+ 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"
+ >
+
+ Config
+
+ )}
+
+ {/* Notification Toggle */}
+
+
{/* Claude Code Terminal Toggle Button */}
setClaudeTerminalOpen(!claudeTerminalOpen)}
@@ -421,6 +457,27 @@ export default function Dashboard() {
+
{/* Main Layout: Charts (Claude Terminal is now global/floating) */}
{/* Main Content - Charts stacked vertically */}
@@ -795,6 +852,15 @@ export default function Dashboard() {