/** * ParallelCoordinates - Custom SVG implementation * Visualizes multi-dimensional optimization data with full control over rendering */ import { useMemo } from 'react'; interface Trial { trial_number: number; values: number[]; params: Record; design_variables?: Record; user_attrs?: Record; 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 NivoParallelCoordinatesProps { trials: Trial[]; objectives: Objective[]; designVariables: DesignVariable[]; paretoFront?: Trial[]; height?: number; } // Colors optimized for dark theme const COLORS = { background: '#0a0f1a', axis: '#3b82f6', axisLabel: '#94a3b8', tick: '#64748b', gridLine: '#1e3a5f', lineFEA: '#10b981', // Green for FEA trials lineNN: '#f59e0b', // Amber for NN trials linePareto: '#00d4e6', // Cyan for Pareto-optimal lineDefault: '#6366f1', // Indigo default }; /** * Get parameter value from trial, checking multiple possible locations */ function getParamValue(trial: Trial, paramName: string): number | null { // Check params object first if (trial.params && typeof trial.params[paramName] === 'number') { return trial.params[paramName]; } // Check design_variables (some backends use this) if (trial.design_variables && typeof trial.design_variables[paramName] === 'number') { return trial.design_variables[paramName]; } // Try case-insensitive match if (trial.params) { const lowerName = paramName.toLowerCase(); for (const [key, value] of Object.entries(trial.params)) { if (key.toLowerCase() === lowerName && typeof value === 'number') { return value; } } } return null; } /** * Get all available parameter names from trials */ function getAvailableParams(trials: Trial[]): string[] { const paramSet = new Set(); for (const trial of trials.slice(0, 10)) { // Sample first 10 trials if (trial.params) { Object.keys(trial.params).forEach(k => paramSet.add(k)); } if (trial.design_variables) { Object.keys(trial.design_variables).forEach(k => paramSet.add(k)); } } return Array.from(paramSet); } export function NivoParallelCoordinates({ trials, objectives: _objectives, designVariables, paretoFront = [], height = 400 }: NivoParallelCoordinatesProps) { const width = 900; const margin = { top: 50, right: 60, bottom: 50, left: 60 }; const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; // Create Pareto set for quick lookup const paretoSet = useMemo(() => { const set = new Set(); paretoFront.forEach(t => set.add(t.trial_number)); return set; }, [paretoFront]); // Process data const { axes, lines, debugInfo } = useMemo(() => { // Get available params from actual trial data const availableParams = getAvailableParams(trials); // Determine which axes to show let axisNames: string[]; if (designVariables.length > 0) { // Use design variables from config, but verify they exist in data axisNames = designVariables .slice(0, 6) .map(dv => dv.name) .filter(name => { // Check if at least some trials have this parameter return trials.slice(0, 20).some(t => getParamValue(t, name) !== null); }); } else { // Fall back to params from trial data axisNames = availableParams.slice(0, 6); } if (!trials.length || axisNames.length < 2) { return { axes: [], lines: [], debugInfo: { trialsCount: trials.length, designVarsCount: designVariables.length, availableParams, axisNames, message: axisNames.length < 2 ? 'Need at least 2 axes with valid data' : 'No trials available' } }; } const axisSpacing = innerWidth / (axisNames.length - 1); // Calculate min/max for each axis from actual data const axesData = axisNames.map((name, i) => { const dv = designVariables.find(d => d.name === name); // Get all valid values from trials const values = trials .map(t => getParamValue(t, name)) .filter((v): v is number => v !== null && isFinite(v)); // Use config bounds if available, otherwise derive from data const dataMin = values.length ? Math.min(...values) : 0; const dataMax = values.length ? Math.max(...values) : 100; const range = dataMax - dataMin; // Add 5% padding to range if derived from data const min = dv?.min ?? (dataMin - range * 0.05); const max = dv?.max ?? (dataMax + range * 0.05); return { name, displayName: name.length > 12 ? name.substring(0, 10) + '...' : name, min, max, x: i * axisSpacing, unit: dv?.unit || '', valueCount: values.length, }; }); // Generate line paths for each trial const linesData = trials.slice(0, 100).map((trial, trialIndex) => { const points: { x: number; y: number }[] = []; axesData.forEach(axis => { const value = getParamValue(trial, axis.name); if (value !== null && isFinite(value)) { const range = axis.max - axis.min; const normalizedY = range > 0 ? (value - axis.min) / range : 0.5; const clampedY = Math.max(0, Math.min(1, normalizedY)); const y = innerHeight - (clampedY * innerHeight); // Flip Y points.push({ x: axis.x, y }); } }); // Need at least 2 points to draw a line if (points.length >= 2) { const pathD = points .map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`) .join(' '); // Determine color based on trial type const isPareto = paretoSet.has(trial.trial_number); const source = trial.source || trial.user_attrs?.source; let color = COLORS.lineDefault; if (isPareto) { color = COLORS.linePareto; } else if (source === 'FEA' || source === 'V10_FEA') { color = COLORS.lineFEA; } else if (source === 'NN') { color = COLORS.lineNN; } return { id: `trial-${trial.trial_number ?? trialIndex}`, d: pathD, color, isPareto, opacity: isPareto ? 0.9 : 0.5, strokeWidth: isPareto ? 2 : 1, }; } return null; }).filter((line): line is NonNullable => line !== null); return { axes: axesData, lines: linesData, debugInfo: { trialsCount: trials.length, designVarsCount: designVariables.length, availableParams, axisNames, linesGenerated: linesData.length, message: linesData.length > 0 ? 'Data processed successfully' : 'No valid line paths generated' } }; }, [trials, designVariables, innerWidth, innerHeight, paretoSet]); // Show debug info if no valid visualization if (axes.length < 2) { return (
Cannot render parallel coordinates

Trials: {debugInfo.trialsCount}

Config design vars: {debugInfo.designVarsCount}

Available params in data: {debugInfo.availableParams.join(', ') || 'none'}

Matched axes: {debugInfo.axisNames?.join(', ') || 'none'}

{debugInfo.message}

); } return (
{/* Debug info */}
{lines.length} of {trials.length} trials rendered {axes.length} axes
{/* Draw grid lines */} {[0, 0.25, 0.5, 0.75, 1].map(pct => ( ))} {/* Draw lines first (so axes appear on top) */} {lines.map((line) => ( ))} {/* Draw axes */} {axes.map((axis) => ( {/* Axis line */} {/* Axis label (top) */} {axis.displayName} {/* Max value label */} {axis.max.toFixed(axis.max > 100 ? 0 : 1)} {/* Min value label */} {axis.min.toFixed(axis.min > 100 ? 0 : 1)} {/* Unit label */} {axis.unit && ( ({axis.unit}) )} {/* Tick marks */} {[0, 0.5, 1].map(pct => ( ))} ))} {/* Legend */} {paretoFront.length > 0 && ( Pareto )} 0 ? 15 : 0})`}> FEA 0 ? 30 : 15})`}> NN
); }