Files
Atomizer/atomizer-dashboard/frontend/src/components/charts/NivoParallelCoordinates.tsx
Anto01 7919511bb2 feat: Phase 1 - Canvas with React Flow
- 8 node types (Model, Solver, DesignVar, Extractor, Objective, Constraint, Algorithm, Surrogate)
- Drag-drop from palette to canvas
- Node configuration panels
- Graph validation with error/warning display
- Intent JSON serialization
- Zustand state management

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:00:35 -05:00

402 lines
12 KiB
TypeScript

/**
* 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<string, number>;
design_variables?: 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 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<string>();
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<number>();
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<typeof line> => 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 (
<div className="p-4 bg-dark-800 rounded-lg">
<div className="text-red-400 font-medium mb-2">
Cannot render parallel coordinates
</div>
<div className="text-sm text-dark-400 space-y-1">
<p>Trials: {debugInfo.trialsCount}</p>
<p>Config design vars: {debugInfo.designVarsCount}</p>
<p>Available params in data: {debugInfo.availableParams.join(', ') || 'none'}</p>
<p>Matched axes: {debugInfo.axisNames?.join(', ') || 'none'}</p>
<p className="text-yellow-400">{debugInfo.message}</p>
</div>
</div>
);
}
return (
<div className="w-full">
{/* Debug info */}
<div className="text-xs text-dark-500 mb-1 flex justify-between px-2">
<span>{lines.length} of {trials.length} trials rendered</span>
<span>{axes.length} axes</span>
</div>
<svg
width={width}
height={height}
style={{ backgroundColor: COLORS.background }}
className="rounded-lg mx-auto block"
viewBox={`0 0 ${width} ${height}`}
>
<g transform={`translate(${margin.left}, ${margin.top})`}>
{/* Draw grid lines */}
{[0, 0.25, 0.5, 0.75, 1].map(pct => (
<line
key={`grid-${pct}`}
x1={0}
y1={innerHeight * (1 - pct)}
x2={innerWidth}
y2={innerHeight * (1 - pct)}
stroke={COLORS.gridLine}
strokeWidth={1}
strokeDasharray="4,4"
opacity={0.3}
/>
))}
{/* Draw lines first (so axes appear on top) */}
<g className="lines">
{lines.map((line) => (
<path
key={line.id}
d={line.d}
fill="none"
stroke={line.color}
strokeWidth={line.strokeWidth}
opacity={line.opacity}
strokeLinecap="round"
strokeLinejoin="round"
/>
))}
</g>
{/* Draw axes */}
{axes.map((axis) => (
<g key={axis.name}>
{/* Axis line */}
<line
x1={axis.x}
y1={0}
x2={axis.x}
y2={innerHeight}
stroke={COLORS.axis}
strokeWidth={2}
/>
{/* Axis label (top) */}
<text
x={axis.x}
y={-25}
textAnchor="middle"
fill={COLORS.axisLabel}
fontSize={11}
fontWeight="500"
>
{axis.displayName}
</text>
{/* Max value label */}
<text
x={axis.x}
y={-8}
textAnchor="middle"
fill={COLORS.tick}
fontSize={9}
>
{axis.max.toFixed(axis.max > 100 ? 0 : 1)}
</text>
{/* Min value label */}
<text
x={axis.x}
y={innerHeight + 15}
textAnchor="middle"
fill={COLORS.tick}
fontSize={9}
>
{axis.min.toFixed(axis.min > 100 ? 0 : 1)}
</text>
{/* Unit label */}
{axis.unit && (
<text
x={axis.x}
y={innerHeight + 28}
textAnchor="middle"
fill={COLORS.tick}
fontSize={8}
opacity={0.7}
>
({axis.unit})
</text>
)}
{/* Tick marks */}
{[0, 0.5, 1].map(pct => (
<line
key={`tick-${axis.name}-${pct}`}
x1={axis.x - 4}
y1={innerHeight * (1 - pct)}
x2={axis.x + 4}
y2={innerHeight * (1 - pct)}
stroke={COLORS.axis}
strokeWidth={1}
/>
))}
</g>
))}
</g>
{/* Legend */}
<g transform={`translate(${width - 120}, 15)`}>
{paretoFront.length > 0 && (
<g transform="translate(0, 0)">
<line x1={0} y1={5} x2={20} y2={5} stroke={COLORS.linePareto} strokeWidth={2} />
<text x={25} y={8} fill={COLORS.axisLabel} fontSize={9}>Pareto</text>
</g>
)}
<g transform={`translate(0, ${paretoFront.length > 0 ? 15 : 0})`}>
<line x1={0} y1={5} x2={20} y2={5} stroke={COLORS.lineFEA} strokeWidth={1} />
<text x={25} y={8} fill={COLORS.axisLabel} fontSize={9}>FEA</text>
</g>
<g transform={`translate(0, ${paretoFront.length > 0 ? 30 : 15})`}>
<line x1={0} y1={5} x2={20} y2={5} stroke={COLORS.lineNN} strokeWidth={1} />
<text x={25} y={8} fill={COLORS.axisLabel} fontSize={9}>NN</text>
</g>
</g>
</svg>
</div>
);
}