feat: Add Protocol 13 adaptive optimization, Plotly charts, and dashboard improvements

## Protocol 13: Adaptive Multi-Objective Optimization
- Iterative FEA + Neural Network surrogate workflow
- Initial FEA sampling, NN training, NN-accelerated search
- FEA validation of top NN predictions, retraining loop
- adaptive_state.json tracks iteration history and best values
- M1 mirror study (V11) with 103 FEA, 3000 NN trials

## Dashboard Visualization Enhancements
- Added Plotly.js interactive charts (parallel coords, Pareto, convergence)
- Lazy loading with React.lazy() for performance
- Code splitting: plotly.js-basic-dist (~1MB vs 3.5MB)
- Chart library toggle (Recharts default, Plotly on-demand)
- ExpandableChart component for full-screen modal views
- ConsoleOutput component for real-time log viewing

## Documentation
- Protocol 13 detailed documentation
- Dashboard visualization guide
- Plotly components README
- Updated run-optimization skill with Mode 5 (adaptive)

## Bug Fixes
- Fixed TypeScript errors in dashboard components
- Fixed Card component to accept ReactNode title
- Removed unused imports across components

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Antoine
2025-12-04 07:41:54 -05:00
parent e74f1ccf36
commit 8cbdbcad78
270 changed files with 15471 additions and 517 deletions

View File

@@ -0,0 +1,225 @@
/**
* PlotlyConvergencePlot - Interactive convergence plot using Plotly
*
* Features:
* - Line plot showing objective vs trial number
* - Best-so-far trace overlay
* - FEA vs NN trial differentiation
* - Hover tooltips with trial details
* - Range slider for zooming
* - Export to PNG/SVG
*/
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
interface Trial {
trial_number: number;
values: number[];
params: Record<string, number>;
user_attrs?: Record<string, any>;
source?: 'FEA' | 'NN' | 'V10_FEA';
}
interface PlotlyConvergencePlotProps {
trials: Trial[];
objectiveIndex?: number;
objectiveName?: string;
direction?: 'minimize' | 'maximize';
height?: number;
showRangeSlider?: boolean;
}
export function PlotlyConvergencePlot({
trials,
objectiveIndex = 0,
objectiveName = 'Objective',
direction = 'minimize',
height = 400,
showRangeSlider = true
}: PlotlyConvergencePlotProps) {
// Process trials and calculate best-so-far
const { feaData, nnData, bestSoFar, allX, allY } = useMemo(() => {
if (!trials.length) return { feaData: { x: [], y: [], text: [] }, nnData: { x: [], y: [], text: [] }, bestSoFar: { x: [], y: [] }, allX: [], allY: [] };
// Sort by trial number
const sorted = [...trials].sort((a, b) => a.trial_number - b.trial_number);
const fea: { x: number[]; y: number[]; text: string[] } = { x: [], y: [], text: [] };
const nn: { x: number[]; y: number[]; text: string[] } = { x: [], y: [], text: [] };
const best: { x: number[]; y: number[] } = { x: [], y: [] };
const xs: number[] = [];
const ys: number[] = [];
let bestValue = direction === 'minimize' ? Infinity : -Infinity;
sorted.forEach(t => {
const val = t.values?.[objectiveIndex] ?? t.user_attrs?.[objectiveName] ?? null;
if (val === null || !isFinite(val)) return;
const source = t.source || t.user_attrs?.source || 'FEA';
const hoverText = `Trial #${t.trial_number}<br>${objectiveName}: ${val.toFixed(4)}<br>Source: ${source}`;
xs.push(t.trial_number);
ys.push(val);
if (source === 'NN') {
nn.x.push(t.trial_number);
nn.y.push(val);
nn.text.push(hoverText);
} else {
fea.x.push(t.trial_number);
fea.y.push(val);
fea.text.push(hoverText);
}
// Update best-so-far
if (direction === 'minimize') {
if (val < bestValue) bestValue = val;
} else {
if (val > bestValue) bestValue = val;
}
best.x.push(t.trial_number);
best.y.push(bestValue);
});
return { feaData: fea, nnData: nn, bestSoFar: best, allX: xs, allY: ys };
}, [trials, objectiveIndex, objectiveName, direction]);
if (!trials.length || allX.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-gray-500">
No trial data available
</div>
);
}
const traces: any[] = [];
// FEA trials scatter
if (feaData.x.length > 0) {
traces.push({
type: 'scatter',
mode: 'markers',
name: `FEA (${feaData.x.length})`,
x: feaData.x,
y: feaData.y,
text: feaData.text,
hoverinfo: 'text',
marker: {
color: '#3B82F6',
size: 8,
opacity: 0.7,
line: { color: '#1E40AF', width: 1 }
}
});
}
// NN trials scatter
if (nnData.x.length > 0) {
traces.push({
type: 'scatter',
mode: 'markers',
name: `NN (${nnData.x.length})`,
x: nnData.x,
y: nnData.y,
text: nnData.text,
hoverinfo: 'text',
marker: {
color: '#F97316',
size: 6,
symbol: 'cross',
opacity: 0.6
}
});
}
// Best-so-far line
if (bestSoFar.x.length > 0) {
traces.push({
type: 'scatter',
mode: 'lines',
name: 'Best So Far',
x: bestSoFar.x,
y: bestSoFar.y,
line: {
color: '#10B981',
width: 3,
shape: 'hv' // Step line
},
hoverinfo: 'y'
});
}
const layout: any = {
height,
margin: { l: 60, r: 30, t: 30, b: showRangeSlider ? 80 : 50 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
xaxis: {
title: 'Trial Number',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB',
rangeslider: showRangeSlider ? { visible: true } : undefined
},
yaxis: {
title: objectiveName,
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
},
legend: {
x: 1,
y: 1,
xanchor: 'right',
bgcolor: 'rgba(255,255,255,0.8)',
bordercolor: '#E5E7EB',
borderwidth: 1
},
font: { family: 'Inter, system-ui, sans-serif' },
hovermode: 'closest'
};
// Best value annotation
const bestVal = direction === 'minimize'
? Math.min(...allY)
: Math.max(...allY);
const bestIdx = allY.indexOf(bestVal);
const bestTrial = allX[bestIdx];
return (
<div className="w-full">
{/* Summary stats */}
<div className="flex gap-6 justify-center mb-3 text-sm">
<div className="text-gray-600">
Best: <span className="font-semibold text-green-600">{bestVal.toFixed(4)}</span>
<span className="text-gray-400 ml-1">(Trial #{bestTrial})</span>
</div>
<div className="text-gray-600">
Current: <span className="font-semibold">{allY[allY.length - 1].toFixed(4)}</span>
</div>
<div className="text-gray-600">
Trials: <span className="font-semibold">{allX.length}</span>
</div>
</div>
<Plot
data={traces}
layout={layout}
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
toImageButtonOptions: {
format: 'png',
filename: 'convergence_plot',
height: 600,
width: 1200,
scale: 2
}
}}
style={{ width: '100%' }}
/>
</div>
);
}

View File

@@ -0,0 +1,221 @@
/**
* PlotlyParallelCoordinates - Interactive parallel coordinates plot using Plotly
*
* Features:
* - Native zoom, pan, and selection
* - Hover tooltips with trial details
* - Brush filtering on each axis
* - FEA vs NN color differentiation
* - Export to PNG/SVG
*/
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
interface Trial {
trial_number: number;
values: number[];
params: Record<string, number>;
user_attrs?: Record<string, any>;
constraint_satisfied?: boolean;
source?: 'FEA' | 'NN' | 'V10_FEA';
}
interface Objective {
name: string;
direction?: 'minimize' | 'maximize';
unit?: string;
}
interface DesignVariable {
name: string;
unit?: string;
min?: number;
max?: number;
}
interface PlotlyParallelCoordinatesProps {
trials: Trial[];
objectives: Objective[];
designVariables: DesignVariable[];
paretoFront?: Trial[];
height?: number;
}
export function PlotlyParallelCoordinates({
trials,
objectives,
designVariables,
paretoFront = [],
height = 500
}: PlotlyParallelCoordinatesProps) {
// Create set of Pareto front trial numbers
const paretoSet = useMemo(() => new Set(paretoFront.map(t => t.trial_number)), [paretoFront]);
// Build dimensions array for parallel coordinates
const { dimensions, colorValues, colorScale } = useMemo(() => {
if (!trials.length) return { dimensions: [], colorValues: [], colorScale: [] };
const dims: any[] = [];
const colors: number[] = [];
// Get all design variable names
const dvNames = designVariables.map(dv => dv.name);
const objNames = objectives.map(obj => obj.name);
// Add design variable dimensions
dvNames.forEach((name, idx) => {
const dv = designVariables[idx];
const values = trials.map(t => t.params[name] ?? 0);
const validValues = values.filter(v => v !== null && v !== undefined && isFinite(v));
if (validValues.length === 0) return;
dims.push({
label: name,
values: values,
range: [
dv?.min ?? Math.min(...validValues),
dv?.max ?? Math.max(...validValues)
],
constraintrange: undefined
});
});
// Add objective dimensions
objNames.forEach((name, idx) => {
const obj = objectives[idx];
const values = trials.map(t => {
// Try to get from values array first, then user_attrs
if (t.values && t.values[idx] !== undefined) {
return t.values[idx];
}
return t.user_attrs?.[name] ?? 0;
});
const validValues = values.filter(v => v !== null && v !== undefined && isFinite(v));
if (validValues.length === 0) return;
dims.push({
label: `${name}${obj.unit ? ` (${obj.unit})` : ''}`,
values: values,
range: [Math.min(...validValues) * 0.95, Math.max(...validValues) * 1.05]
});
});
// Build color array: 0 = V10_FEA, 1 = FEA, 2 = NN, 3 = Pareto
trials.forEach(t => {
const source = t.source || t.user_attrs?.source || 'FEA';
const isPareto = paretoSet.has(t.trial_number);
if (isPareto) {
colors.push(3); // Pareto - special color
} else if (source === 'NN') {
colors.push(2); // NN trials
} else if (source === 'V10_FEA') {
colors.push(0); // V10 FEA
} else {
colors.push(1); // V11 FEA
}
});
// Color scale: V10_FEA (light blue), FEA (blue), NN (orange), Pareto (green)
const scale: [number, string][] = [
[0, '#93C5FD'], // V10_FEA - light blue
[0.33, '#2563EB'], // FEA - blue
[0.66, '#F97316'], // NN - orange
[1, '#10B981'] // Pareto - green
];
return { dimensions: dims, colorValues: colors, colorScale: scale };
}, [trials, objectives, designVariables, paretoSet]);
if (!trials.length || dimensions.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-gray-500">
No trial data available for parallel coordinates
</div>
);
}
// Count trial types for legend
const feaCount = trials.filter(t => {
const source = t.source || t.user_attrs?.source || 'FEA';
return source === 'FEA' || source === 'V10_FEA';
}).length;
const nnCount = trials.filter(t => {
const source = t.source || t.user_attrs?.source || 'FEA';
return source === 'NN';
}).length;
return (
<div className="w-full">
{/* Legend */}
<div className="flex gap-4 justify-center mb-2 text-sm">
<div className="flex items-center gap-1.5">
<div className="w-4 h-1 rounded" style={{ backgroundColor: '#2563EB' }} />
<span className="text-gray-600">FEA ({feaCount})</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-4 h-1 rounded" style={{ backgroundColor: '#F97316' }} />
<span className="text-gray-600">NN ({nnCount})</span>
</div>
{paretoFront.length > 0 && (
<div className="flex items-center gap-1.5">
<div className="w-4 h-1 rounded" style={{ backgroundColor: '#10B981' }} />
<span className="text-gray-600">Pareto ({paretoFront.length})</span>
</div>
)}
</div>
<Plot
data={[
{
type: 'parcoords',
line: {
color: colorValues,
colorscale: colorScale as any,
showscale: false
},
dimensions: dimensions,
labelangle: -30,
labelfont: {
size: 11,
color: '#374151'
},
tickfont: {
size: 10,
color: '#6B7280'
}
} as any
]}
layout={{
height: height,
margin: { l: 80, r: 80, t: 30, b: 30 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
font: {
family: 'Inter, system-ui, sans-serif'
}
}}
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
toImageButtonOptions: {
format: 'png',
filename: 'parallel_coordinates',
height: 800,
width: 1400,
scale: 2
}
}}
style={{ width: '100%' }}
/>
<p className="text-xs text-gray-500 text-center mt-2">
Drag along axes to filter. Double-click to reset.
</p>
</div>
);
}

View File

@@ -0,0 +1,209 @@
/**
* PlotlyParameterImportance - Interactive parameter importance chart using Plotly
*
* Features:
* - Horizontal bar chart showing correlation/importance
* - Color coding by positive/negative correlation
* - Hover tooltips with details
* - Sortable by importance
*/
import { useMemo, useState } from 'react';
import Plot from 'react-plotly.js';
interface Trial {
trial_number: number;
values: number[];
params: Record<string, number>;
user_attrs?: Record<string, any>;
}
interface DesignVariable {
name: string;
unit?: string;
}
interface PlotlyParameterImportanceProps {
trials: Trial[];
designVariables: DesignVariable[];
objectiveIndex?: number;
objectiveName?: string;
height?: number;
}
// Calculate Pearson correlation coefficient
function pearsonCorrelation(x: number[], y: number[]): number {
const n = x.length;
if (n === 0) return 0;
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 PlotlyParameterImportance({
trials,
designVariables,
objectiveIndex = 0,
objectiveName = 'Objective',
height = 400
}: PlotlyParameterImportanceProps) {
const [sortBy, setSortBy] = useState<'importance' | 'name'>('importance');
// Calculate correlations for each parameter
const correlations = useMemo(() => {
if (!trials.length || !designVariables.length) return [];
// Get objective values
const objValues = trials.map(t => {
if (t.values && t.values[objectiveIndex] !== undefined) {
return t.values[objectiveIndex];
}
return t.user_attrs?.[objectiveName] ?? null;
}).filter((v): v is number => v !== null && isFinite(v));
if (objValues.length < 3) return []; // Need at least 3 points for correlation
const results: { name: string; correlation: number; absCorrelation: number }[] = [];
designVariables.forEach(dv => {
const paramValues = trials
.map((t) => {
const objVal = t.values?.[objectiveIndex] ?? t.user_attrs?.[objectiveName];
if (objVal === null || objVal === undefined || !isFinite(objVal)) return null;
return { param: t.params[dv.name], obj: objVal };
})
.filter((v): v is { param: number; obj: number } => v !== null && v.param !== undefined);
if (paramValues.length < 3) return;
const x = paramValues.map(v => v.param);
const y = paramValues.map(v => v.obj);
const corr = pearsonCorrelation(x, y);
results.push({
name: dv.name,
correlation: corr,
absCorrelation: Math.abs(corr)
});
});
// Sort by absolute correlation or name
if (sortBy === 'importance') {
results.sort((a, b) => b.absCorrelation - a.absCorrelation);
} else {
results.sort((a, b) => a.name.localeCompare(b.name));
}
return results;
}, [trials, designVariables, objectiveIndex, objectiveName, sortBy]);
if (!correlations.length) {
return (
<div className="flex items-center justify-center h-64 text-gray-500">
Not enough data to calculate parameter importance
</div>
);
}
// Build bar chart data
const names = correlations.map(c => c.name);
const values = correlations.map(c => c.correlation);
const colors = values.map(v => v > 0 ? '#EF4444' : '#22C55E'); // Red for positive (worse), Green for negative (better) when minimizing
const hoverTexts = correlations.map(c =>
`${c.name}<br>Correlation: ${c.correlation.toFixed(4)}<br>|r|: ${c.absCorrelation.toFixed(4)}<br>${c.correlation > 0 ? 'Higher → Higher objective' : 'Higher → Lower objective'}`
);
return (
<div className="w-full">
{/* Controls */}
<div className="flex justify-between items-center mb-3">
<div className="text-sm text-gray-600">
Correlation with <span className="font-semibold">{objectiveName}</span>
</div>
<div className="flex gap-2">
<button
onClick={() => setSortBy('importance')}
className={`px-3 py-1 text-xs rounded ${sortBy === 'importance' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700'}`}
>
By Importance
</button>
<button
onClick={() => setSortBy('name')}
className={`px-3 py-1 text-xs rounded ${sortBy === 'name' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700'}`}
>
By Name
</button>
</div>
</div>
<Plot
data={[
{
type: 'bar',
orientation: 'h',
y: names,
x: values,
text: hoverTexts,
hoverinfo: 'text',
marker: {
color: colors,
line: { color: '#fff', width: 1 }
}
}
]}
layout={{
height: Math.max(height, correlations.length * 30 + 80),
margin: { l: 150, r: 30, t: 10, b: 50 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
xaxis: {
title: { text: 'Correlation Coefficient' },
range: [-1, 1],
gridcolor: '#E5E7EB',
zerolinecolor: '#9CA3AF',
zerolinewidth: 2
},
yaxis: {
automargin: true
},
font: { family: 'Inter, system-ui, sans-serif', size: 11 },
bargap: 0.3
}}
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
toImageButtonOptions: {
format: 'png',
filename: 'parameter_importance',
height: 600,
width: 800,
scale: 2
}
}}
style={{ width: '100%' }}
/>
{/* Legend */}
<div className="flex gap-6 justify-center mt-3 text-xs">
<div className="flex items-center gap-1.5">
<div className="w-4 h-3 rounded" style={{ backgroundColor: '#EF4444' }} />
<span className="text-gray-600">Positive correlation (higher param higher objective)</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-4 h-3 rounded" style={{ backgroundColor: '#22C55E' }} />
<span className="text-gray-600">Negative correlation (higher param lower objective)</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,300 @@
/**
* PlotlyParetoPlot - Interactive Pareto front visualization using Plotly
*
* Features:
* - 2D scatter with Pareto front highlighted
* - 3D scatter for 3-objective problems
* - Hover tooltips with trial details
* - Click to select trials
* - FEA vs NN differentiation
* - Zoom, pan, and export
*/
import { useMemo, useState } from 'react';
import Plot from 'react-plotly.js';
interface Trial {
trial_number: number;
values: number[];
params: Record<string, number>;
user_attrs?: Record<string, any>;
source?: 'FEA' | 'NN' | 'V10_FEA';
}
interface Objective {
name: string;
direction?: 'minimize' | 'maximize';
unit?: string;
}
interface PlotlyParetoPlotProps {
trials: Trial[];
paretoFront: Trial[];
objectives: Objective[];
height?: number;
}
export function PlotlyParetoPlot({
trials,
paretoFront,
objectives,
height = 500
}: PlotlyParetoPlotProps) {
const [viewMode, setViewMode] = useState<'2d' | '3d'>(objectives.length >= 3 ? '3d' : '2d');
const [selectedObjectives, setSelectedObjectives] = useState<[number, number, number]>([0, 1, 2]);
const paretoSet = useMemo(() => new Set(paretoFront.map(t => t.trial_number)), [paretoFront]);
// Separate trials by source and Pareto status
const { feaTrials, nnTrials, paretoTrials } = useMemo(() => {
const fea: Trial[] = [];
const nn: Trial[] = [];
const pareto: Trial[] = [];
trials.forEach(t => {
const source = t.source || t.user_attrs?.source || 'FEA';
if (paretoSet.has(t.trial_number)) {
pareto.push(t);
} else if (source === 'NN') {
nn.push(t);
} else {
fea.push(t);
}
});
return { feaTrials: fea, nnTrials: nn, paretoTrials: pareto };
}, [trials, paretoSet]);
// Helper to get objective value
const getObjValue = (trial: Trial, idx: number): number => {
if (trial.values && trial.values[idx] !== undefined) {
return trial.values[idx];
}
const objName = objectives[idx]?.name;
return trial.user_attrs?.[objName] ?? 0;
};
// Build hover text
const buildHoverText = (trial: Trial): string => {
const lines = [`Trial #${trial.trial_number}`];
objectives.forEach((obj, i) => {
const val = getObjValue(trial, i);
lines.push(`${obj.name}: ${val.toFixed(4)}${obj.unit ? ` ${obj.unit}` : ''}`);
});
const source = trial.source || trial.user_attrs?.source || 'FEA';
lines.push(`Source: ${source}`);
return lines.join('<br>');
};
// Create trace data
const createTrace = (
trialList: Trial[],
name: string,
color: string,
symbol: string,
size: number,
opacity: number
) => {
const [i, j, k] = selectedObjectives;
if (viewMode === '3d' && objectives.length >= 3) {
return {
type: 'scatter3d' as const,
mode: 'markers' as const,
name,
x: trialList.map(t => getObjValue(t, i)),
y: trialList.map(t => getObjValue(t, j)),
z: trialList.map(t => getObjValue(t, k)),
text: trialList.map(buildHoverText),
hoverinfo: 'text' as const,
marker: {
color,
size,
symbol,
opacity,
line: { color: '#fff', width: 1 }
}
};
} else {
return {
type: 'scatter' as const,
mode: 'markers' as const,
name,
x: trialList.map(t => getObjValue(t, i)),
y: trialList.map(t => getObjValue(t, j)),
text: trialList.map(buildHoverText),
hoverinfo: 'text' as const,
marker: {
color,
size,
symbol,
opacity,
line: { color: '#fff', width: 1 }
}
};
}
};
const traces = [
// FEA trials (background, less prominent)
createTrace(feaTrials, `FEA (${feaTrials.length})`, '#93C5FD', 'circle', 8, 0.6),
// NN trials (background, less prominent)
createTrace(nnTrials, `NN (${nnTrials.length})`, '#FDBA74', 'cross', 8, 0.5),
// Pareto front (highlighted)
createTrace(paretoTrials, `Pareto (${paretoTrials.length})`, '#10B981', 'diamond', 12, 1.0)
].filter(trace => (trace.x as number[]).length > 0);
const [i, j, k] = selectedObjectives;
const layout: any = viewMode === '3d' && objectives.length >= 3
? {
height,
margin: { l: 50, r: 50, t: 30, b: 50 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
scene: {
xaxis: {
title: objectives[i]?.name || 'Objective 1',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
},
yaxis: {
title: objectives[j]?.name || 'Objective 2',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
},
zaxis: {
title: objectives[k]?.name || 'Objective 3',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
},
bgcolor: 'rgba(0,0,0,0)'
},
legend: {
x: 1,
y: 1,
bgcolor: 'rgba(255,255,255,0.8)',
bordercolor: '#E5E7EB',
borderwidth: 1
},
font: { family: 'Inter, system-ui, sans-serif' }
}
: {
height,
margin: { l: 60, r: 30, t: 30, b: 60 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
xaxis: {
title: objectives[i]?.name || 'Objective 1',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
},
yaxis: {
title: objectives[j]?.name || 'Objective 2',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
},
legend: {
x: 1,
y: 1,
xanchor: 'right',
bgcolor: 'rgba(255,255,255,0.8)',
bordercolor: '#E5E7EB',
borderwidth: 1
},
font: { family: 'Inter, system-ui, sans-serif' },
hovermode: 'closest' as const
};
if (!trials.length) {
return (
<div className="flex items-center justify-center h-64 text-gray-500">
No trial data available
</div>
);
}
return (
<div className="w-full">
{/* Controls */}
<div className="flex gap-4 items-center justify-between mb-3">
<div className="flex gap-2 items-center">
{objectives.length >= 3 && (
<div className="flex rounded-lg overflow-hidden border border-gray-300">
<button
onClick={() => setViewMode('2d')}
className={`px-3 py-1 text-sm ${viewMode === '2d' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
>
2D
</button>
<button
onClick={() => setViewMode('3d')}
className={`px-3 py-1 text-sm ${viewMode === '3d' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
>
3D
</button>
</div>
)}
</div>
{/* Objective selectors */}
<div className="flex gap-2 items-center text-sm">
<label className="text-gray-600">X:</label>
<select
value={selectedObjectives[0]}
onChange={(e) => setSelectedObjectives([parseInt(e.target.value), selectedObjectives[1], selectedObjectives[2]])}
className="px-2 py-1 border border-gray-300 rounded text-sm"
>
{objectives.map((obj, idx) => (
<option key={idx} value={idx}>{obj.name}</option>
))}
</select>
<label className="text-gray-600 ml-2">Y:</label>
<select
value={selectedObjectives[1]}
onChange={(e) => setSelectedObjectives([selectedObjectives[0], parseInt(e.target.value), selectedObjectives[2]])}
className="px-2 py-1 border border-gray-300 rounded text-sm"
>
{objectives.map((obj, idx) => (
<option key={idx} value={idx}>{obj.name}</option>
))}
</select>
{viewMode === '3d' && objectives.length >= 3 && (
<>
<label className="text-gray-600 ml-2">Z:</label>
<select
value={selectedObjectives[2]}
onChange={(e) => setSelectedObjectives([selectedObjectives[0], selectedObjectives[1], parseInt(e.target.value)])}
className="px-2 py-1 border border-gray-300 rounded text-sm"
>
{objectives.map((obj, idx) => (
<option key={idx} value={idx}>{obj.name}</option>
))}
</select>
</>
)}
</div>
</div>
<Plot
data={traces as any}
layout={layout}
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['lasso2d'],
toImageButtonOptions: {
format: 'png',
filename: 'pareto_front',
height: 800,
width: 1200,
scale: 2
}
}}
style={{ width: '100%' }}
/>
</div>
);
}

View File

@@ -0,0 +1,217 @@
# Plotly Chart Components
Interactive visualization components using Plotly.js for the Atomizer Dashboard.
## Overview
These components provide enhanced interactivity compared to Recharts:
- Native zoom, pan, and selection
- Export to PNG/SVG
- Hover tooltips with detailed information
- Brush filtering (parallel coordinates)
- 3D visualization support
## Components
### PlotlyParallelCoordinates
Multi-dimensional data visualization showing relationships between all variables.
```tsx
import { PlotlyParallelCoordinates } from '../components/plotly';
<PlotlyParallelCoordinates
trials={allTrials}
objectives={studyMetadata.objectives}
designVariables={studyMetadata.design_variables}
paretoFront={paretoFront}
height={450}
/>
```
**Props:**
| Prop | Type | Description |
|------|------|-------------|
| trials | Trial[] | All trial data |
| objectives | Objective[] | Objective definitions |
| designVariables | DesignVariable[] | Design variable definitions |
| paretoFront | Trial[] | Pareto-optimal trials (optional) |
| height | number | Chart height in pixels |
**Features:**
- Drag on axes to filter data
- Double-click to reset filters
- Color coding: FEA (blue), NN (orange), Pareto (green)
### PlotlyParetoPlot
2D/3D scatter plot for Pareto front visualization.
```tsx
<PlotlyParetoPlot
trials={allTrials}
paretoFront={paretoFront}
objectives={studyMetadata.objectives}
height={350}
/>
```
**Props:**
| Prop | Type | Description |
|------|------|-------------|
| trials | Trial[] | All trial data |
| paretoFront | Trial[] | Pareto-optimal trials |
| objectives | Objective[] | Objective definitions |
| height | number | Chart height in pixels |
**Features:**
- Toggle between 2D and 3D views
- Axis selector for multi-objective problems
- Click to select trials
- Hover for trial details
### PlotlyConvergencePlot
Optimization progress over trials.
```tsx
<PlotlyConvergencePlot
trials={allTrials}
objectiveIndex={0}
objectiveName="weighted_objective"
direction="minimize"
height={350}
/>
```
**Props:**
| Prop | Type | Description |
|------|------|-------------|
| trials | Trial[] | All trial data |
| objectiveIndex | number | Which objective to plot |
| objectiveName | string | Objective display name |
| direction | 'minimize' \| 'maximize' | Optimization direction |
| height | number | Chart height |
| showRangeSlider | boolean | Show zoom slider |
**Features:**
- Scatter points for each trial
- Best-so-far step line
- Range slider for zooming
- FEA vs NN differentiation
### PlotlyParameterImportance
Correlation-based parameter sensitivity analysis.
```tsx
<PlotlyParameterImportance
trials={allTrials}
designVariables={studyMetadata.design_variables}
objectiveIndex={0}
objectiveName="weighted_objective"
height={350}
/>
```
**Props:**
| Prop | Type | Description |
|------|------|-------------|
| trials | Trial[] | All trial data |
| designVariables | DesignVariable[] | Design variables |
| objectiveIndex | number | Which objective |
| objectiveName | string | Objective display name |
| height | number | Chart height |
**Features:**
- Horizontal bar chart of correlations
- Sort by importance or name
- Color: Red (positive), Green (negative)
- Pearson correlation coefficient
## Bundle Optimization
To minimize bundle size, we use:
1. **plotly.js-basic-dist**: Smaller bundle (~1MB vs 3.5MB)
- Includes: scatter, bar, parcoords
- Excludes: 3D plots, maps, animations
2. **Lazy Loading**: Components loaded on demand
```tsx
const PlotlyParetoPlot = lazy(() =>
import('./plotly/PlotlyParetoPlot')
.then(m => ({ default: m.PlotlyParetoPlot }))
);
```
3. **Code Splitting**: Vite config separates Plotly into its own chunk
```ts
manualChunks: {
plotly: ['plotly.js-basic-dist', 'react-plotly.js']
}
```
## Usage with Suspense
Always wrap Plotly components with Suspense:
```tsx
<Suspense fallback={<ChartLoading />}>
<PlotlyParetoPlot {...props} />
</Suspense>
```
## Type Definitions
```typescript
interface Trial {
trial_number: number;
values: number[];
params: Record<string, number>;
user_attrs?: Record<string, any>;
source?: 'FEA' | 'NN' | 'V10_FEA';
}
interface Objective {
name: string;
direction?: 'minimize' | 'maximize';
unit?: string;
}
interface DesignVariable {
name: string;
unit?: string;
min?: number;
max?: number;
}
```
## Styling
Components use transparent backgrounds for dark theme compatibility:
- `paper_bgcolor: 'rgba(0,0,0,0)'`
- `plot_bgcolor: 'rgba(0,0,0,0)'`
- Font: Inter, system-ui, sans-serif
- Grid colors: Tailwind gray palette
## Export Options
All Plotly charts include a mode bar with:
- Download PNG
- Download SVG (via menu)
- Zoom, Pan, Reset
- Auto-scale
Configure export in the `config` prop:
```tsx
config={{
toImageButtonOptions: {
format: 'png',
filename: 'my_chart',
height: 600,
width: 1200,
scale: 2
}
}}
```

View File

@@ -0,0 +1,15 @@
/**
* Plotly-based interactive chart components
*
* These components provide enhanced interactivity compared to Recharts:
* - Native zoom/pan
* - Brush selection on axes
* - 3D views for multi-objective problems
* - Export to PNG/SVG
* - Detailed hover tooltips
*/
export { PlotlyParallelCoordinates } from './PlotlyParallelCoordinates';
export { PlotlyParetoPlot } from './PlotlyParetoPlot';
export { PlotlyConvergencePlot } from './PlotlyConvergencePlot';
export { PlotlyParameterImportance } from './PlotlyParameterImportance';