- 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>
402 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|