/** * 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 * - Pareto front connection line * - FEA vs NN differentiation * - Constraint satisfaction highlighting * - Dark mode styling * - Zoom, pan, and export */ import { useMemo, useState } from 'react'; import Plot from 'react-plotly.js'; interface Trial { trial_number: number; values: number[]; params: Record; user_attrs?: Record; source?: 'FEA' | 'NN' | 'V10_FEA'; constraint_satisfied?: boolean; } interface Objective { name: string; direction?: 'minimize' | 'maximize'; unit?: string; } interface PlotlyParetoPlotProps { trials: Trial[]; paretoFront: Trial[]; objectives: Objective[]; height?: number; showParetoLine?: boolean; showInfeasible?: boolean; } export function PlotlyParetoPlot({ trials, paretoFront, objectives, height = 500, showParetoLine = true, showInfeasible = true }: 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, Pareto status, and constraint satisfaction const { feaTrials, nnTrials, paretoTrials, infeasibleTrials, stats } = useMemo(() => { const fea: Trial[] = []; const nn: Trial[] = []; const pareto: Trial[] = []; const infeasible: Trial[] = []; trials.forEach(t => { const source = t.source || t.user_attrs?.source || 'FEA'; const isFeasible = t.constraint_satisfied !== false && t.user_attrs?.constraint_satisfied !== false; if (!isFeasible && showInfeasible) { infeasible.push(t); } else if (paretoSet.has(t.trial_number)) { pareto.push(t); } else if (source === 'NN') { nn.push(t); } else { fea.push(t); } }); // Calculate statistics const stats = { totalTrials: trials.length, paretoCount: pareto.length, feaCount: fea.length + pareto.filter(t => (t.source || 'FEA') !== 'NN').length, nnCount: nn.length + pareto.filter(t => t.source === 'NN').length, infeasibleCount: infeasible.length, hypervolume: 0 // Could calculate if needed }; return { feaTrials: fea, nnTrials: nn, paretoTrials: pareto, infeasibleTrials: infeasible, stats }; }, [trials, paretoSet, showInfeasible]); // 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('
'); }; // 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 } } }; } }; // Sort Pareto trials by first objective for line connection const sortedParetoTrials = useMemo(() => { const [i] = selectedObjectives; return [...paretoTrials].sort((a, b) => getObjValue(a, i) - getObjValue(b, i)); }, [paretoTrials, selectedObjectives]); // Create Pareto front line trace (2D only) const createParetoLine = () => { if (!showParetoLine || viewMode === '3d' || sortedParetoTrials.length < 2) return null; const [i, j] = selectedObjectives; return { type: 'scatter' as const, mode: 'lines' as const, name: 'Pareto Front', x: sortedParetoTrials.map(t => getObjValue(t, i)), y: sortedParetoTrials.map(t => getObjValue(t, j)), line: { color: '#10B981', width: 2, dash: 'dot' }, hoverinfo: 'skip' as const, showlegend: false }; }; const traces = [ // Infeasible trials (background, red X) ...(showInfeasible && infeasibleTrials.length > 0 ? [ createTrace(infeasibleTrials, `Infeasible (${infeasibleTrials.length})`, '#EF4444', 'x', 7, 0.4) ] : []), // FEA trials (blue circles) createTrace(feaTrials, `FEA (${feaTrials.length})`, '#3B82F6', 'circle', 8, 0.6), // NN trials (purple diamonds) createTrace(nnTrials, `NN (${nnTrials.length})`, '#A855F7', 'diamond', 8, 0.5), // Pareto front line (2D only) createParetoLine(), // Pareto front points (highlighted) createTrace(sortedParetoTrials, `Pareto (${sortedParetoTrials.length})`, '#10B981', 'star', 14, 1.0) ].filter(trace => trace && (trace.x as number[]).length > 0); const [i, j, k] = selectedObjectives; // Dark mode color scheme const colors = { text: '#E5E7EB', textMuted: '#9CA3AF', grid: 'rgba(255,255,255,0.1)', zeroline: 'rgba(255,255,255,0.2)', legendBg: 'rgba(30,30,30,0.9)', legendBorder: 'rgba(255,255,255,0.1)' }; const layout: any = viewMode === '3d' && objectives.length >= 3 ? { height, margin: { l: 50, r: 50, t: 30, b: 50 }, paper_bgcolor: 'transparent', plot_bgcolor: 'transparent', scene: { xaxis: { title: { text: objectives[i]?.name || 'Objective 1', font: { color: colors.text } }, gridcolor: colors.grid, zerolinecolor: colors.zeroline, tickfont: { color: colors.textMuted } }, yaxis: { title: { text: objectives[j]?.name || 'Objective 2', font: { color: colors.text } }, gridcolor: colors.grid, zerolinecolor: colors.zeroline, tickfont: { color: colors.textMuted } }, zaxis: { title: { text: objectives[k]?.name || 'Objective 3', font: { color: colors.text } }, gridcolor: colors.grid, zerolinecolor: colors.zeroline, tickfont: { color: colors.textMuted } }, bgcolor: 'transparent' }, legend: { x: 1, y: 1, font: { color: colors.text }, bgcolor: colors.legendBg, bordercolor: colors.legendBorder, borderwidth: 1 }, font: { family: 'Inter, system-ui, sans-serif', color: colors.text } } : { height, margin: { l: 60, r: 30, t: 30, b: 60 }, paper_bgcolor: 'transparent', plot_bgcolor: 'transparent', xaxis: { title: { text: objectives[i]?.name || 'Objective 1', font: { color: colors.text } }, gridcolor: colors.grid, zerolinecolor: colors.zeroline, tickfont: { color: colors.textMuted } }, yaxis: { title: { text: objectives[j]?.name || 'Objective 2', font: { color: colors.text } }, gridcolor: colors.grid, zerolinecolor: colors.zeroline, tickfont: { color: colors.textMuted } }, legend: { x: 1, y: 1, xanchor: 'right', font: { color: colors.text }, bgcolor: colors.legendBg, bordercolor: colors.legendBorder, borderwidth: 1 }, font: { family: 'Inter, system-ui, sans-serif', color: colors.text }, hovermode: 'closest' as const }; if (!trials.length) { return (
No trial data available
); } return (
{/* Stats Bar */}
Pareto: {stats.paretoCount}
FEA: {stats.feaCount}
NN: {stats.nnCount}
{stats.infeasibleCount > 0 && (
Infeasible: {stats.infeasibleCount}
)}
{/* Controls */}
{objectives.length >= 3 && (
)}
{/* Objective selectors */}
{viewMode === '3d' && objectives.length >= 3 && ( <> )}
{/* Pareto Front Table for 2D view */} {viewMode === '2d' && sortedParetoTrials.length > 0 && (
{sortedParetoTrials.slice(0, 10).map(trial => ( ))}
Trial {objectives[i]?.name || 'Obj 1'} {objectives[j]?.name || 'Obj 2'} Source
#{trial.trial_number} {getObjValue(trial, i).toExponential(4)} {getObjValue(trial, j).toExponential(4)} {trial.source || trial.user_attrs?.source || 'FEA'}
{sortedParetoTrials.length > 10 && (
Showing 10 of {sortedParetoTrials.length} Pareto-optimal solutions
)}
)}
); }