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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
217
atomizer-dashboard/frontend/src/components/plotly/README.md
Normal file
217
atomizer-dashboard/frontend/src/components/plotly/README.md
Normal 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
|
||||
}
|
||||
}}
|
||||
```
|
||||
15
atomizer-dashboard/frontend/src/components/plotly/index.ts
Normal file
15
atomizer-dashboard/frontend/src/components/plotly/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user