feat: Enhance dashboard with charts, study report viewer, and pruning tracking
- Add ConvergencePlot component with running best, statistics, gradient fill - Add ParameterImportanceChart with Pearson correlation analysis - Add StudyReportViewer with KaTeX math rendering and full markdown support - Update pruning endpoint to query Optuna database directly - Add /report endpoint for STUDY_REPORT.md files - Fix chart data transformation for single/multi-objective studies - Update Protocol 13 documentation with new components - Update generate-report skill with dashboard integration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
256
atomizer-dashboard/frontend/src/components/ConvergencePlot.tsx
Normal file
256
atomizer-dashboard/frontend/src/components/ConvergencePlot.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Convergence Plot
|
||||
* Shows optimization progress over time with running best and improvement rate
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
Area,
|
||||
ComposedChart,
|
||||
Legend
|
||||
} from 'recharts';
|
||||
|
||||
interface Trial {
|
||||
trial_number: number;
|
||||
values: number[];
|
||||
state?: string;
|
||||
}
|
||||
|
||||
interface ConvergencePlotProps {
|
||||
trials: Trial[];
|
||||
objectiveIndex?: number;
|
||||
objectiveName?: string;
|
||||
direction?: 'minimize' | 'maximize';
|
||||
}
|
||||
|
||||
export function ConvergencePlot({
|
||||
trials,
|
||||
objectiveIndex = 0,
|
||||
objectiveName = 'Objective',
|
||||
direction = 'minimize'
|
||||
}: ConvergencePlotProps) {
|
||||
const convergenceData = useMemo(() => {
|
||||
if (!trials || trials.length === 0) return [];
|
||||
|
||||
// Sort by trial number
|
||||
const sortedTrials = [...trials]
|
||||
.filter(t => t.values && t.values.length > objectiveIndex && t.state !== 'FAIL')
|
||||
.sort((a, b) => a.trial_number - b.trial_number);
|
||||
|
||||
if (sortedTrials.length === 0) return [];
|
||||
|
||||
let runningBest = direction === 'minimize' ? Infinity : -Infinity;
|
||||
const data = sortedTrials.map((trial, idx) => {
|
||||
const value = trial.values[objectiveIndex];
|
||||
|
||||
// Update running best
|
||||
if (direction === 'minimize') {
|
||||
runningBest = Math.min(runningBest, value);
|
||||
} else {
|
||||
runningBest = Math.max(runningBest, value);
|
||||
}
|
||||
|
||||
// Calculate improvement from first trial
|
||||
const firstValue = sortedTrials[0].values[objectiveIndex];
|
||||
const improvement = direction === 'minimize'
|
||||
? ((firstValue - runningBest) / firstValue) * 100
|
||||
: ((runningBest - firstValue) / firstValue) * 100;
|
||||
|
||||
return {
|
||||
trial: trial.trial_number,
|
||||
value,
|
||||
best: runningBest,
|
||||
improvement: Math.max(0, improvement),
|
||||
index: idx + 1
|
||||
};
|
||||
});
|
||||
|
||||
return data;
|
||||
}, [trials, objectiveIndex, direction]);
|
||||
|
||||
// Calculate statistics
|
||||
const stats = useMemo(() => {
|
||||
if (convergenceData.length === 0) return null;
|
||||
|
||||
const values = convergenceData.map(d => d.value);
|
||||
const best = convergenceData[convergenceData.length - 1]?.best ?? 0;
|
||||
const first = convergenceData[0]?.value ?? 0;
|
||||
const improvement = direction === 'minimize'
|
||||
? ((first - best) / first) * 100
|
||||
: ((best - first) / first) * 100;
|
||||
|
||||
// Calculate when we found 90% of the improvement
|
||||
const targetImprovement = 0.9 * improvement;
|
||||
let trialsTo90 = convergenceData.length;
|
||||
for (let i = 0; i < convergenceData.length; i++) {
|
||||
if (convergenceData[i].improvement >= targetImprovement) {
|
||||
trialsTo90 = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
best,
|
||||
first,
|
||||
improvement: Math.max(0, improvement),
|
||||
totalTrials: convergenceData.length,
|
||||
trialsTo90,
|
||||
mean: values.reduce((a, b) => a + b, 0) / values.length,
|
||||
std: Math.sqrt(values.map(v => Math.pow(v - values.reduce((a, b) => a + b, 0) / values.length, 2)).reduce((a, b) => a + b, 0) / values.length)
|
||||
};
|
||||
}, [convergenceData, direction]);
|
||||
|
||||
if (convergenceData.length === 0) {
|
||||
return (
|
||||
<div className="bg-dark-700 rounded-lg p-6 border border-dark-500 shadow-sm">
|
||||
<h3 className="text-lg font-semibold mb-4 text-dark-100">Convergence Plot</h3>
|
||||
<div className="h-64 flex items-center justify-center text-dark-400">
|
||||
No trial data available
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-dark-700 rounded-lg p-6 border border-dark-500 shadow-sm">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-dark-100">Convergence Plot</h3>
|
||||
<p className="text-sm text-dark-400 mt-1">
|
||||
{objectiveName} over {convergenceData.length} trials
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="flex gap-6 text-sm">
|
||||
<div className="text-right">
|
||||
<div className="text-dark-400">Best</div>
|
||||
<div className="font-semibold text-green-400">{stats.best.toFixed(4)}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-dark-400">Improvement</div>
|
||||
<div className="font-semibold text-blue-400">{stats.improvement.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-dark-400">90% at trial</div>
|
||||
<div className="font-semibold text-purple-400">#{stats.trialsTo90}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={convergenceData} margin={{ top: 10, right: 30, left: 10, bottom: 10 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#60a5fa" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#60a5fa" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="trial"
|
||||
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
||||
stroke="#334155"
|
||||
label={{ value: 'Trial', position: 'bottom', offset: -5, fill: '#94a3b8', fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
||||
stroke="#334155"
|
||||
tickFormatter={(v) => v.toFixed(2)}
|
||||
label={{ value: objectiveName, angle: -90, position: 'insideLeft', fill: '#94a3b8', fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid #334155',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.3)'
|
||||
}}
|
||||
labelStyle={{ color: '#e2e8f0' }}
|
||||
formatter={(value: number, name: string) => {
|
||||
const label = name === 'value' ? 'Trial Value' :
|
||||
name === 'best' ? 'Running Best' : name;
|
||||
return [value.toFixed(4), label];
|
||||
}}
|
||||
labelFormatter={(label) => `Trial #${label}`}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
height={36}
|
||||
wrapperStyle={{ color: '#94a3b8' }}
|
||||
formatter={(value) => value === 'value' ? 'Trial Value' : 'Running Best'}
|
||||
/>
|
||||
|
||||
{/* Area under trial values */}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#60a5fa"
|
||||
fill="url(#colorValue)"
|
||||
strokeWidth={0}
|
||||
/>
|
||||
|
||||
{/* Trial values as scatter points */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#60a5fa"
|
||||
strokeWidth={1}
|
||||
dot={{ fill: '#60a5fa', r: 3 }}
|
||||
activeDot={{ fill: '#93c5fd', r: 5 }}
|
||||
/>
|
||||
|
||||
{/* Running best line */}
|
||||
<Line
|
||||
type="stepAfter"
|
||||
dataKey="best"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2.5}
|
||||
dot={false}
|
||||
/>
|
||||
|
||||
{/* Reference line at best value */}
|
||||
{stats && (
|
||||
<ReferenceLine
|
||||
y={stats.best}
|
||||
stroke="#10b981"
|
||||
strokeDasharray="5 5"
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
)}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Summary stats bar */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-4 mt-4 pt-4 border-t border-dark-500 text-center text-sm">
|
||||
<div>
|
||||
<div className="text-dark-400">First Value</div>
|
||||
<div className="font-medium text-dark-100">{stats.first.toFixed(4)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-dark-400">Mean</div>
|
||||
<div className="font-medium text-dark-100">{stats.mean.toFixed(4)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-dark-400">Std Dev</div>
|
||||
<div className="font-medium text-dark-100">{stats.std.toFixed(4)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-dark-400">Total Trials</div>
|
||||
<div className="font-medium text-dark-100">{stats.totalTrials}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Parameter Importance Chart
|
||||
* Shows which design parameters have the most impact on objectives
|
||||
* Uses correlation analysis between parameters and objective values
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||
|
||||
interface Trial {
|
||||
trial_number: number;
|
||||
values: number[];
|
||||
params: Record<string, number>;
|
||||
}
|
||||
|
||||
interface DesignVariable {
|
||||
name: string;
|
||||
parameter?: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
interface ParameterImportanceChartProps {
|
||||
trials: Trial[];
|
||||
designVariables: DesignVariable[];
|
||||
objectiveIndex?: number;
|
||||
objectiveName?: string;
|
||||
}
|
||||
|
||||
// Calculate Pearson correlation coefficient
|
||||
function pearsonCorrelation(x: number[], y: number[]): number {
|
||||
if (x.length !== y.length || x.length < 2) return 0;
|
||||
|
||||
const n = x.length;
|
||||
const sumX = x.reduce((a, b) => a + b, 0);
|
||||
const sumY = y.reduce((a, b) => a + b, 0);
|
||||
const sumXY = x.reduce((acc, xi, i) => acc + xi * y[i], 0);
|
||||
const sumX2 = x.reduce((acc, xi) => acc + xi * xi, 0);
|
||||
const sumY2 = y.reduce((acc, yi) => acc + yi * yi, 0);
|
||||
|
||||
const numerator = n * sumXY - sumX * sumY;
|
||||
const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
|
||||
|
||||
if (denominator === 0) return 0;
|
||||
return numerator / denominator;
|
||||
}
|
||||
|
||||
export function ParameterImportanceChart({
|
||||
trials,
|
||||
designVariables,
|
||||
objectiveIndex = 0,
|
||||
objectiveName = 'Objective'
|
||||
}: ParameterImportanceChartProps) {
|
||||
const importanceData = useMemo(() => {
|
||||
if (!trials || trials.length < 3 || !designVariables || designVariables.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Extract objective values
|
||||
const objectiveValues = trials
|
||||
.filter(t => t.values && t.values.length > objectiveIndex)
|
||||
.map(t => t.values[objectiveIndex]);
|
||||
|
||||
if (objectiveValues.length < 3) return [];
|
||||
|
||||
// Calculate correlation for each parameter
|
||||
const importances = designVariables.map(dv => {
|
||||
const paramName = dv.parameter || dv.name;
|
||||
const paramValues = trials
|
||||
.filter(t => t.params && paramName in t.params && t.values && t.values.length > objectiveIndex)
|
||||
.map(t => t.params[paramName]);
|
||||
|
||||
const corrObjectiveValues = trials
|
||||
.filter(t => t.params && paramName in t.params && t.values && t.values.length > objectiveIndex)
|
||||
.map(t => t.values[objectiveIndex]);
|
||||
|
||||
if (paramValues.length < 3) return { name: dv.name, importance: 0, correlation: 0 };
|
||||
|
||||
const correlation = pearsonCorrelation(paramValues, corrObjectiveValues);
|
||||
// Use absolute correlation as importance (sign indicates direction)
|
||||
const importance = Math.abs(correlation);
|
||||
|
||||
return {
|
||||
name: dv.name,
|
||||
importance: importance * 100, // Convert to percentage
|
||||
correlation,
|
||||
direction: correlation > 0 ? 'positive' : 'negative'
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by importance (descending)
|
||||
return importances.sort((a, b) => b.importance - a.importance);
|
||||
}, [trials, designVariables, objectiveIndex]);
|
||||
|
||||
if (importanceData.length === 0) {
|
||||
return (
|
||||
<div className="bg-dark-700 rounded-lg p-6 border border-dark-500 shadow-sm">
|
||||
<h3 className="text-lg font-semibold mb-4 text-dark-100">Parameter Importance</h3>
|
||||
<div className="h-64 flex items-center justify-center text-dark-400">
|
||||
Need at least 3 trials for correlation analysis
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Color based on correlation direction
|
||||
const getBarColor = (entry: typeof importanceData[0]) => {
|
||||
if (entry.correlation > 0) {
|
||||
// Positive correlation (increasing param increases objective) - red for minimization
|
||||
return '#f87171';
|
||||
} else {
|
||||
// Negative correlation (increasing param decreases objective) - green for minimization
|
||||
return '#34d399';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-dark-700 rounded-lg p-6 border border-dark-500 shadow-sm">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-dark-100">Parameter Importance</h3>
|
||||
<p className="text-sm text-dark-400 mt-1">
|
||||
Correlation with {objectiveName} ({trials.length} trials)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={importanceData}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 30, left: 120, bottom: 5 }}
|
||||
>
|
||||
<XAxis
|
||||
type="number"
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(v) => `${v.toFixed(0)}%`}
|
||||
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
||||
stroke="#334155"
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={110}
|
||||
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
||||
stroke="#334155"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, _name: string, props: any) => {
|
||||
const corr = props.payload.correlation;
|
||||
return [
|
||||
`${value.toFixed(1)}% (r=${corr.toFixed(3)})`,
|
||||
'Importance'
|
||||
];
|
||||
}}
|
||||
contentStyle={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid #334155',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.3)'
|
||||
}}
|
||||
labelStyle={{ color: '#e2e8f0' }}
|
||||
/>
|
||||
<Bar dataKey="importance" radius={[0, 4, 4, 0]}>
|
||||
{importanceData.map((entry, index) => (
|
||||
<Cell key={index} fill={getBarColor(entry)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex gap-6 justify-center mt-4 text-sm border-t border-dark-500 pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: '#34d399' }} />
|
||||
<span className="text-dark-300">Negative correlation (helps minimize)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: '#f87171' }} />
|
||||
<span className="text-dark-300">Positive correlation (hurts minimize)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
278
atomizer-dashboard/frontend/src/components/StudyReportViewer.tsx
Normal file
278
atomizer-dashboard/frontend/src/components/StudyReportViewer.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Study Report Viewer
|
||||
* Displays the STUDY_REPORT.md file with proper markdown rendering
|
||||
* Includes math equation support via KaTeX and syntax highlighting
|
||||
*/
|
||||
|
||||
import { useState, useEffect } 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 { FileText, X, ExternalLink, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface StudyReportViewerProps {
|
||||
studyId: string;
|
||||
studyPath?: string;
|
||||
}
|
||||
|
||||
export function StudyReportViewer({ studyId, studyPath }: StudyReportViewerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [markdown, setMarkdown] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchReport = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/optimization/studies/${studyId}/report`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setError('No STUDY_REPORT.md found for this study');
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
setMarkdown(data.content);
|
||||
} catch (err) {
|
||||
setError(`Failed to load report: ${err}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !markdown) {
|
||||
fetchReport();
|
||||
}
|
||||
}, [isOpen, studyId]);
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
<FileText size={16} />
|
||||
Study Report
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
|
||||
<div className="bg-dark-800 rounded-xl shadow-2xl w-[90vw] max-w-5xl h-[85vh] flex flex-col border border-dark-600">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-600">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="text-primary-400" size={24} />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">Study Report</h2>
|
||||
<p className="text-sm text-dark-400">{studyId}/STUDY_REPORT.md</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={fetchReport}
|
||||
className="p-2 hover:bg-dark-600 rounded-lg transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={18} className={`text-dark-300 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
{studyPath && (
|
||||
<a
|
||||
href={`file:///${studyPath.replace(/\\/g, '/')}/STUDY_REPORT.md`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 hover:bg-dark-600 rounded-lg transition-colors"
|
||||
title="Open in editor"
|
||||
>
|
||||
<ExternalLink size={18} className="text-dark-300" />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-2 hover:bg-dark-600 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} className="text-dark-300" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-8 bg-dark-700">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-400"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-dark-400">
|
||||
<FileText size={48} className="mb-4 opacity-50" />
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={fetchReport}
|
||||
className="mt-4 px-4 py-2 text-sm bg-dark-600 hover:bg-dark-500 text-dark-200 rounded-lg transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{markdown && !loading && (
|
||||
<article className="markdown-body">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
// Custom heading styles
|
||||
h1: ({children}) => (
|
||||
<h1 className="text-3xl font-bold text-dark-50 mb-6 pb-3 border-b border-dark-500">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({children}) => (
|
||||
<h2 className="text-2xl font-semibold text-dark-100 mt-8 mb-4 pb-2 border-b border-dark-600">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({children}) => (
|
||||
<h3 className="text-xl font-semibold text-dark-100 mt-6 mb-3">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({children}) => (
|
||||
<h4 className="text-lg font-medium text-dark-200 mt-4 mb-2">
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
// Paragraph styling
|
||||
p: ({children}) => (
|
||||
<p className="text-dark-200 leading-relaxed mb-4">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
// List styling
|
||||
ul: ({children}) => (
|
||||
<ul className="list-disc list-inside text-dark-200 mb-4 space-y-1 ml-2">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({children}) => (
|
||||
<ol className="list-decimal list-inside text-dark-200 mb-4 space-y-1 ml-2">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({children}) => (
|
||||
<li className="text-dark-200 leading-relaxed">
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
// Strong/bold text
|
||||
strong: ({children}) => (
|
||||
<strong className="font-semibold text-dark-100">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
// Emphasis/italic
|
||||
em: ({children}) => (
|
||||
<em className="italic text-dark-200">
|
||||
{children}
|
||||
</em>
|
||||
),
|
||||
// Links
|
||||
a: ({href, children}) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary-400 hover:text-primary-300 underline decoration-primary-400/30 hover:decoration-primary-300"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
// Inline code
|
||||
code: ({className, children, ...props}) => {
|
||||
const isInline = !className;
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="bg-dark-800 text-primary-300 px-1.5 py-0.5 rounded text-sm font-mono">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
// Code block
|
||||
return (
|
||||
<code className={`${className} text-sm`} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
// Code blocks with pre wrapper
|
||||
pre: ({children}) => (
|
||||
<pre className="bg-dark-900 text-dark-100 p-4 rounded-lg overflow-x-auto mb-4 text-sm font-mono border border-dark-600">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
// Blockquotes
|
||||
blockquote: ({children}) => (
|
||||
<blockquote className="border-l-4 border-primary-500 pl-4 py-1 my-4 bg-dark-800/50 rounded-r italic text-dark-300">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
// Tables
|
||||
table: ({children}) => (
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="min-w-full border-collapse border border-dark-500 text-sm">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({children}) => (
|
||||
<thead className="bg-dark-600">
|
||||
{children}
|
||||
</thead>
|
||||
),
|
||||
th: ({children}) => (
|
||||
<th className="border border-dark-500 px-4 py-2 text-left font-semibold text-dark-100">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({children}) => (
|
||||
<td className="border border-dark-500 px-4 py-2 text-dark-200">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
tr: ({children}) => (
|
||||
<tr className="hover:bg-dark-600/50 transition-colors">
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
// Horizontal rule
|
||||
hr: () => (
|
||||
<hr className="border-dark-500 my-8" />
|
||||
),
|
||||
// Images
|
||||
img: ({src, alt}) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ''}
|
||||
className="max-w-full h-auto rounded-lg border border-dark-500 my-4"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import { StudyCard } from '../components/dashboard/StudyCard';
|
||||
import { OptimizerPanel } from '../components/OptimizerPanel';
|
||||
import { ParetoPlot } from '../components/ParetoPlot';
|
||||
import { ParallelCoordinatesPlot } from '../components/ParallelCoordinatesPlot';
|
||||
import { ParameterImportanceChart } from '../components/ParameterImportanceChart';
|
||||
import { ConvergencePlot } from '../components/ConvergencePlot';
|
||||
import { StudyReportViewer } from '../components/StudyReportViewer';
|
||||
import type { Study, Trial, ConvergenceDataPoint, ParameterSpaceDataPoint } from '../types';
|
||||
|
||||
export default function Dashboard() {
|
||||
@@ -25,6 +28,10 @@ export default function Dashboard() {
|
||||
const [expandedTrials, setExpandedTrials] = useState<Set<number>>(new Set());
|
||||
const [sortBy, setSortBy] = useState<'performance' | 'chronological'>('performance');
|
||||
|
||||
// Parameter Space axis selection
|
||||
const [paramXIndex, setParamXIndex] = useState(0);
|
||||
const [paramYIndex, setParamYIndex] = useState(1);
|
||||
|
||||
// Protocol 13: New state for metadata and Pareto front
|
||||
const [studyMetadata, setStudyMetadata] = useState<any>(null);
|
||||
const [paretoFront, setParetoFront] = useState<any[]>([]);
|
||||
@@ -102,7 +109,8 @@ export default function Dashboard() {
|
||||
|
||||
apiClient.getStudyPruning(selectedStudyId)
|
||||
.then(data => {
|
||||
setPrunedCount(data.pruned_trials?.length || 0);
|
||||
// Use count if available (new API), fallback to array length (legacy)
|
||||
setPrunedCount(data.count ?? data.pruned_trials?.length ?? 0);
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
@@ -129,18 +137,29 @@ export default function Dashboard() {
|
||||
})
|
||||
.catch(err => console.error('Failed to load Pareto front:', err));
|
||||
|
||||
// Fetch ALL trials (not just Pareto) for parallel coordinates
|
||||
// Fetch ALL trials (not just Pareto) for parallel coordinates and charts
|
||||
fetch(`/api/optimization/studies/${selectedStudyId}/history`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
// Transform to match the format expected by ParallelCoordinatesPlot
|
||||
const trialsData = data.trials.map((t: any) => ({
|
||||
trial_number: t.trial_number,
|
||||
values: t.values || [],
|
||||
params: t.design_variables || {},
|
||||
user_attrs: t.user_attrs || {},
|
||||
constraint_satisfied: t.constraint_satisfied !== false
|
||||
}));
|
||||
// Transform to match the format expected by charts
|
||||
// API returns 'objectives' (array) for multi-objective, 'objective' (number) for single
|
||||
const trialsData = data.trials.map((t: any) => {
|
||||
// Build values array: use objectives if available, otherwise wrap single objective
|
||||
let values: number[] = [];
|
||||
if (t.objectives && Array.isArray(t.objectives)) {
|
||||
values = t.objectives;
|
||||
} else if (t.objective !== null && t.objective !== undefined) {
|
||||
values = [t.objective];
|
||||
}
|
||||
|
||||
return {
|
||||
trial_number: t.trial_number,
|
||||
values,
|
||||
params: t.design_variables || {},
|
||||
user_attrs: t.user_attrs || {},
|
||||
constraint_satisfied: t.constraint_satisfied !== false
|
||||
};
|
||||
});
|
||||
setAllTrialsRaw(trialsData);
|
||||
})
|
||||
.catch(err => console.error('Failed to load all trials:', err));
|
||||
@@ -204,8 +223,8 @@ export default function Dashboard() {
|
||||
const params = Object.values(trial.design_variables);
|
||||
return {
|
||||
trial_number: trial.trial_number,
|
||||
x: params[0] || 0,
|
||||
y: params[1] || 0,
|
||||
x: params[paramXIndex] || 0,
|
||||
y: params[paramYIndex] || 0,
|
||||
objective: trial.objective,
|
||||
isBest: trial.objective === bestValue,
|
||||
};
|
||||
@@ -293,6 +312,9 @@ export default function Dashboard() {
|
||||
<p className="text-dark-300 mt-1">Real-time optimization monitoring</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedStudyId && (
|
||||
<StudyReportViewer studyId={selectedStudyId} />
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
// Open Optuna dashboard on port 8081
|
||||
@@ -332,6 +354,18 @@ export default function Dashboard() {
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="col-span-9">
|
||||
{/* Study Name Header */}
|
||||
{selectedStudyId && (
|
||||
<div className="mb-4 pb-3 border-b border-dark-600">
|
||||
<h2 className="text-xl font-semibold text-primary-300">
|
||||
{selectedStudyId}
|
||||
</h2>
|
||||
{studyMetadata?.description && (
|
||||
<p className="text-sm text-dark-400 mt-1">{studyMetadata.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<MetricCard label="Total Trials" value={allTrials.length} />
|
||||
@@ -391,6 +425,31 @@ export default function Dashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Enhanced Charts: Convergence + Parameter Importance */}
|
||||
{allTrialsRaw.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
<ConvergencePlot
|
||||
trials={allTrialsRaw}
|
||||
objectiveIndex={0}
|
||||
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
|
||||
direction="minimize"
|
||||
/>
|
||||
{/* Parameter Importance needs design_variables from metadata or inferred from trials */}
|
||||
{(studyMetadata?.design_variables?.length > 0 || (allTrialsRaw[0]?.params && Object.keys(allTrialsRaw[0].params).length > 0)) && (
|
||||
<ParameterImportanceChart
|
||||
trials={allTrialsRaw}
|
||||
designVariables={
|
||||
studyMetadata?.design_variables?.length > 0
|
||||
? studyMetadata.design_variables
|
||||
: Object.keys(allTrialsRaw[0]?.params || {}).map(name => ({ name }))
|
||||
}
|
||||
objectiveIndex={0}
|
||||
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
{/* Convergence Chart */}
|
||||
@@ -437,8 +496,36 @@ export default function Dashboard() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Parameter Space Chart */}
|
||||
<Card title={`Parameter Space (${paramNames[0] || 'X'} vs ${paramNames[1] || 'Y'})`}>
|
||||
{/* Parameter Space Chart with Selectable Axes */}
|
||||
<Card title={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span>Parameter Space</span>
|
||||
{paramNames.length > 2 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-dark-400">X:</span>
|
||||
<select
|
||||
value={paramXIndex}
|
||||
onChange={(e) => setParamXIndex(Number(e.target.value))}
|
||||
className="bg-dark-600 text-dark-100 px-2 py-1 rounded text-xs border border-dark-500"
|
||||
>
|
||||
{paramNames.map((name, idx) => (
|
||||
<option key={idx} value={idx}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-dark-400">Y:</span>
|
||||
<select
|
||||
value={paramYIndex}
|
||||
onChange={(e) => setParamYIndex(Number(e.target.value))}
|
||||
className="bg-dark-600 text-dark-100 px-2 py-1 rounded text-xs border border-dark-500"
|
||||
>
|
||||
{paramNames.map((name, idx) => (
|
||||
<option key={idx} value={idx}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}>
|
||||
{parameterSpaceData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ScatterChart>
|
||||
@@ -447,15 +534,15 @@ export default function Dashboard() {
|
||||
type="number"
|
||||
dataKey="x"
|
||||
stroke="#94a3b8"
|
||||
name={paramNames[0] || 'X'}
|
||||
label={{ value: paramNames[0] || 'Parameter 1', position: 'insideBottom', offset: -5, fill: '#94a3b8' }}
|
||||
name={paramNames[paramXIndex] || 'X'}
|
||||
label={{ value: paramNames[paramXIndex] || 'Parameter X', position: 'insideBottom', offset: -5, fill: '#94a3b8' }}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="y"
|
||||
stroke="#94a3b8"
|
||||
name={paramNames[1] || 'Y'}
|
||||
label={{ value: paramNames[1] || 'Parameter 2', angle: -90, position: 'insideLeft', fill: '#94a3b8' }}
|
||||
name={paramNames[paramYIndex] || 'Y'}
|
||||
label={{ value: paramNames[paramYIndex] || 'Parameter Y', angle: -90, position: 'insideLeft', fill: '#94a3b8' }}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ strokeDasharray: '3 3' }}
|
||||
@@ -552,14 +639,38 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Preview */}
|
||||
{/* Quick Preview - Show ALL metrics */}
|
||||
{!isExpanded && trial.results && Object.keys(trial.results).length > 0 && (
|
||||
<div className="text-xs text-primary-300 flex flex-wrap gap-3 mt-2">
|
||||
{trial.results.mass && (
|
||||
<span>Mass: {trial.results.mass.toFixed(2)}g</span>
|
||||
)}
|
||||
{trial.results.frequency && (
|
||||
<span>Freq: {trial.results.frequency.toFixed(2)}Hz</span>
|
||||
{Object.entries(trial.results).slice(0, 6).map(([key, val]) => {
|
||||
// Format value based on type
|
||||
const formatValue = (v: unknown): string => {
|
||||
if (typeof v === 'number') {
|
||||
// Use fewer decimals for quick preview
|
||||
return Math.abs(v) < 0.01 ? v.toExponential(2) : v.toFixed(2);
|
||||
}
|
||||
if (Array.isArray(v)) return `[${v.length}]`;
|
||||
return String(v);
|
||||
};
|
||||
// Format key: snake_case to Title Case, abbreviate long names
|
||||
const formatKey = (k: string): string => {
|
||||
const short = k.replace(/_/g, ' ')
|
||||
.replace(/rel /g, 'Δ')
|
||||
.replace(/filtered rms/g, 'fRMS')
|
||||
.replace(/global rms/g, 'gRMS')
|
||||
.replace(/ vs /g, '/')
|
||||
.replace(/mfg /g, '')
|
||||
.replace(/optician workload/g, 'work');
|
||||
return short.length > 15 ? short.slice(0, 12) + '...' : short;
|
||||
};
|
||||
return (
|
||||
<span key={key} title={`${key}: ${val}`}>
|
||||
{formatKey(key)}: {formatValue(val)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{Object.keys(trial.results).length > 6 && (
|
||||
<span className="text-dark-400">+{Object.keys(trial.results).length - 6} more</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user