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:
@@ -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