271 lines
8.2 KiB
TypeScript
271 lines
8.2 KiB
TypeScript
|
|
/**
|
||
|
|
* Parallel Coordinates Plot - Protocol 13
|
||
|
|
* High-dimensional visualization for multi-objective Pareto fronts
|
||
|
|
* Shows objectives and design variables as parallel axes
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { useState } from 'react';
|
||
|
|
|
||
|
|
interface ParetoTrial {
|
||
|
|
trial_number: number;
|
||
|
|
values: number[];
|
||
|
|
params: Record<string, number>;
|
||
|
|
constraint_satisfied?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Objective {
|
||
|
|
name: string;
|
||
|
|
type: 'minimize' | 'maximize';
|
||
|
|
unit?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface DesignVariable {
|
||
|
|
name: string;
|
||
|
|
unit?: string;
|
||
|
|
min: number;
|
||
|
|
max: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ParallelCoordinatesPlotProps {
|
||
|
|
paretoData: ParetoTrial[];
|
||
|
|
objectives: Objective[];
|
||
|
|
designVariables: DesignVariable[];
|
||
|
|
}
|
||
|
|
|
||
|
|
export function ParallelCoordinatesPlot({
|
||
|
|
paretoData,
|
||
|
|
objectives,
|
||
|
|
designVariables
|
||
|
|
}: ParallelCoordinatesPlotProps) {
|
||
|
|
const [hoveredTrial, setHoveredTrial] = useState<number | null>(null);
|
||
|
|
const [selectedTrials, setSelectedTrials] = useState<Set<number>>(new Set());
|
||
|
|
|
||
|
|
if (paretoData.length === 0) {
|
||
|
|
return (
|
||
|
|
<div className="bg-dark-700 rounded-lg p-6 border border-dark-600">
|
||
|
|
<h3 className="text-lg font-semibold mb-4 text-dark-100">Parallel Coordinates</h3>
|
||
|
|
<div className="h-96 flex items-center justify-center text-dark-300">
|
||
|
|
No Pareto front data yet
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Combine objectives and design variables into axes
|
||
|
|
const axes: Array<{name: string, label: string, type: 'objective' | 'param'}> = [
|
||
|
|
...objectives.map((obj, i) => ({
|
||
|
|
name: `obj_${i}`,
|
||
|
|
label: obj.unit ? `${obj.name} (${obj.unit})` : obj.name,
|
||
|
|
type: 'objective' as const
|
||
|
|
})),
|
||
|
|
...designVariables.map(dv => ({
|
||
|
|
name: dv.name,
|
||
|
|
label: dv.unit ? `${dv.name} (${dv.unit})` : dv.name,
|
||
|
|
type: 'param' as const
|
||
|
|
}))
|
||
|
|
];
|
||
|
|
|
||
|
|
// Normalize data to [0, 1] for each axis
|
||
|
|
const normalizedData = paretoData.map(trial => {
|
||
|
|
const allValues: number[] = [];
|
||
|
|
|
||
|
|
// Add objectives
|
||
|
|
trial.values.forEach(val => allValues.push(val));
|
||
|
|
|
||
|
|
// Add design variables
|
||
|
|
designVariables.forEach(dv => {
|
||
|
|
allValues.push(trial.params[dv.name]);
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
trial_number: trial.trial_number,
|
||
|
|
values: allValues,
|
||
|
|
feasible: trial.constraint_satisfied !== false
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
// Calculate min/max for each axis
|
||
|
|
const ranges = axes.map((_, axisIdx) => {
|
||
|
|
const values = normalizedData.map(d => d.values[axisIdx]);
|
||
|
|
return {
|
||
|
|
min: Math.min(...values),
|
||
|
|
max: Math.max(...values)
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
// Normalize function
|
||
|
|
const normalize = (value: number, axisIdx: number): number => {
|
||
|
|
const range = ranges[axisIdx];
|
||
|
|
if (range.max === range.min) return 0.5;
|
||
|
|
return (value - range.min) / (range.max - range.min);
|
||
|
|
};
|
||
|
|
|
||
|
|
// Chart dimensions
|
||
|
|
const width = 800;
|
||
|
|
const height = 400;
|
||
|
|
const margin = { top: 80, right: 20, bottom: 40, left: 20 };
|
||
|
|
const plotWidth = width - margin.left - margin.right;
|
||
|
|
const plotHeight = height - margin.top - margin.bottom;
|
||
|
|
|
||
|
|
const axisSpacing = plotWidth / (axes.length - 1);
|
||
|
|
|
||
|
|
// Toggle trial selection
|
||
|
|
const toggleTrial = (trialNum: number) => {
|
||
|
|
const newSelected = new Set(selectedTrials);
|
||
|
|
if (newSelected.has(trialNum)) {
|
||
|
|
newSelected.delete(trialNum);
|
||
|
|
} else {
|
||
|
|
newSelected.add(trialNum);
|
||
|
|
}
|
||
|
|
setSelectedTrials(newSelected);
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="bg-dark-700 rounded-lg p-6 border border-dark-600">
|
||
|
|
<div className="flex items-center justify-between mb-4">
|
||
|
|
<h3 className="text-lg font-semibold text-dark-100">
|
||
|
|
Parallel Coordinates ({paretoData.length} solutions)
|
||
|
|
</h3>
|
||
|
|
{selectedTrials.size > 0 && (
|
||
|
|
<button
|
||
|
|
onClick={() => setSelectedTrials(new Set())}
|
||
|
|
className="text-xs px-3 py-1 bg-dark-600 hover:bg-dark-500 rounded text-dark-200"
|
||
|
|
>
|
||
|
|
Clear Selection ({selectedTrials.size})
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<svg width={width} height={height} className="overflow-visible">
|
||
|
|
<g transform={`translate(${margin.left}, ${margin.top})`}>
|
||
|
|
{/* Draw axes */}
|
||
|
|
{axes.map((axis, i) => {
|
||
|
|
const x = i * axisSpacing;
|
||
|
|
return (
|
||
|
|
<g key={axis.name} transform={`translate(${x}, 0)`}>
|
||
|
|
{/* Axis line */}
|
||
|
|
<line
|
||
|
|
y1={0}
|
||
|
|
y2={plotHeight}
|
||
|
|
stroke="#475569"
|
||
|
|
strokeWidth={2}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Axis label */}
|
||
|
|
<text
|
||
|
|
y={-10}
|
||
|
|
textAnchor="middle"
|
||
|
|
fill="#94a3b8"
|
||
|
|
fontSize={12}
|
||
|
|
className="select-none"
|
||
|
|
transform={`rotate(-45, 0, -10)`}
|
||
|
|
>
|
||
|
|
{axis.label}
|
||
|
|
</text>
|
||
|
|
|
||
|
|
{/* Min/max labels */}
|
||
|
|
<text
|
||
|
|
y={plotHeight + 15}
|
||
|
|
textAnchor="middle"
|
||
|
|
fill="#64748b"
|
||
|
|
fontSize={10}
|
||
|
|
>
|
||
|
|
{ranges[i].min.toFixed(2)}
|
||
|
|
</text>
|
||
|
|
<text
|
||
|
|
y={-25}
|
||
|
|
textAnchor="middle"
|
||
|
|
fill="#64748b"
|
||
|
|
fontSize={10}
|
||
|
|
>
|
||
|
|
{ranges[i].max.toFixed(2)}
|
||
|
|
</text>
|
||
|
|
</g>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
|
||
|
|
{/* Draw lines for each trial */}
|
||
|
|
{normalizedData.map(trial => {
|
||
|
|
const isHovered = hoveredTrial === trial.trial_number;
|
||
|
|
const isSelected = selectedTrials.has(trial.trial_number);
|
||
|
|
const isHighlighted = isHovered || isSelected;
|
||
|
|
|
||
|
|
// Build path
|
||
|
|
const pathData = axes.map((_, i) => {
|
||
|
|
const x = i * axisSpacing;
|
||
|
|
const normalizedY = normalize(trial.values[i], i);
|
||
|
|
const y = plotHeight * (1 - normalizedY);
|
||
|
|
return i === 0 ? `M ${x} ${y}` : `L ${x} ${y}`;
|
||
|
|
}).join(' ');
|
||
|
|
|
||
|
|
return (
|
||
|
|
<path
|
||
|
|
key={trial.trial_number}
|
||
|
|
d={pathData}
|
||
|
|
fill="none"
|
||
|
|
stroke={
|
||
|
|
isSelected ? '#fbbf24' :
|
||
|
|
trial.feasible ? '#10b981' : '#ef4444'
|
||
|
|
}
|
||
|
|
strokeWidth={isHighlighted ? 2.5 : 1}
|
||
|
|
opacity={
|
||
|
|
selectedTrials.size > 0
|
||
|
|
? (isSelected ? 1 : 0.1)
|
||
|
|
: (isHighlighted ? 1 : 0.4)
|
||
|
|
}
|
||
|
|
strokeLinecap="round"
|
||
|
|
strokeLinejoin="round"
|
||
|
|
className="transition-all duration-200 cursor-pointer"
|
||
|
|
onMouseEnter={() => setHoveredTrial(trial.trial_number)}
|
||
|
|
onMouseLeave={() => setHoveredTrial(null)}
|
||
|
|
onClick={() => toggleTrial(trial.trial_number)}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
|
||
|
|
{/* Hover tooltip */}
|
||
|
|
{hoveredTrial !== null && (
|
||
|
|
<g transform={`translate(${plotWidth + 10}, 20)`}>
|
||
|
|
<rect
|
||
|
|
x={0}
|
||
|
|
y={0}
|
||
|
|
width={120}
|
||
|
|
height={60}
|
||
|
|
fill="#1e293b"
|
||
|
|
stroke="#334155"
|
||
|
|
strokeWidth={1}
|
||
|
|
rx={4}
|
||
|
|
/>
|
||
|
|
<text x={10} y={20} fill="#e2e8f0" fontSize={12} fontWeight="bold">
|
||
|
|
Trial #{hoveredTrial}
|
||
|
|
</text>
|
||
|
|
<text x={10} y={38} fill="#94a3b8" fontSize={10}>
|
||
|
|
Click to select
|
||
|
|
</text>
|
||
|
|
<text x={10} y={52} fill="#94a3b8" fontSize={10}>
|
||
|
|
{selectedTrials.has(hoveredTrial) ? '✓ Selected' : '○ Not selected'}
|
||
|
|
</text>
|
||
|
|
</g>
|
||
|
|
)}
|
||
|
|
</g>
|
||
|
|
</svg>
|
||
|
|
|
||
|
|
{/* Legend */}
|
||
|
|
<div className="flex gap-6 justify-center mt-4 text-sm">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<div className="w-8 h-0.5 bg-green-400" />
|
||
|
|
<span className="text-dark-200">Feasible</span>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<div className="w-8 h-0.5 bg-red-400" />
|
||
|
|
<span className="text-dark-200">Infeasible</span>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<div className="w-8 h-0.5 bg-yellow-400" style={{ height: '2px' }} />
|
||
|
|
<span className="text-dark-200">Selected</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|