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:
Antoine
2025-12-02 22:01:49 -05:00
parent ec5e42d733
commit 75d7036193
10 changed files with 2917 additions and 66 deletions

View File

@@ -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>
)}