feat: Add dashboard chat integration and MCP server
Major changes: - Dashboard: WebSocket-based chat with session management - Dashboard: New chat components (ChatPane, ChatInput, ModeToggle) - Dashboard: Enhanced UI with parallel coordinates chart - MCP Server: New atomizer-tools server for Claude integration - Extractors: Enhanced Zernike OPD extractor - Reports: Improved report generator New studies (configs and scripts only): - M1 Mirror: Cost reduction campaign studies - Simple Beam, Simple Bracket, UAV Arm studies Note: Large iteration data (2_iterations/, best_design_archive/) excluded via .gitignore - kept on local Gitea only. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* 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,
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { NivoParallelCoordinates } from './NivoParallelCoordinates';
|
||||
Reference in New Issue
Block a user