feat: Add WebSocket live updates and convergence visualization

Phase 4 - Live Updates:
- Create useOptimizationStream hook for real-time trial updates
- Replace polling with WebSocket subscription in SpecRenderer
- Auto-report errors to ErrorPanel via panel store
- Add progress tracking (FEA count, NN count, best trial)

Phase 5 - Convergence Visualization:
- Add ConvergenceSparkline component for mini line charts
- Add ProgressRing component for circular progress indicator
- Update ObjectiveNode to show convergence trend sparkline
- Add history field to ObjectiveNodeData schema
- Add live progress indicator centered on canvas when running

Bug fixes:
- Fix TypeScript errors in FloatingIntrospectionPanel (type casts)
- Fix ValidationPanel using wrong store method (selectNode vs setSelectedNodeId)
- Fix NodeConfigPanelV2 unused state variable
- Fix specValidator source.extractor_id path
- Clean up unused imports across components
This commit is contained in:
2026-01-21 21:48:35 -05:00
parent c224b16ac3
commit 2cb8dccc3a
10 changed files with 764 additions and 167 deletions

View File

@@ -0,0 +1,240 @@
/**
* ConvergenceSparkline - Tiny SVG chart showing optimization convergence
*
* Displays the last N trial values as a mini line chart.
* Used on ObjectiveNode to show convergence trend.
*/
import { useMemo } from 'react';
interface ConvergenceSparklineProps {
/** Array of values (most recent last) */
values: number[];
/** Width in pixels */
width?: number;
/** Height in pixels */
height?: number;
/** Line color */
color?: string;
/** Best value line color */
bestColor?: string;
/** Whether to show the best value line */
showBest?: boolean;
/** Direction: minimize shows lower as better, maximize shows higher as better */
direction?: 'minimize' | 'maximize';
/** Show dots at each point */
showDots?: boolean;
/** Number of points to display */
maxPoints?: number;
}
export function ConvergenceSparkline({
values,
width = 80,
height = 24,
color = '#60a5fa',
bestColor = '#34d399',
showBest = true,
direction = 'minimize',
showDots = false,
maxPoints = 20,
}: ConvergenceSparklineProps) {
const { path, bestY, points } = useMemo(() => {
if (!values || values.length === 0) {
return { path: '', bestY: null, points: [], minVal: 0, maxVal: 1 };
}
// Take last N points
const data = values.slice(-maxPoints);
if (data.length === 0) {
return { path: '', bestY: null, points: [], minVal: 0, maxVal: 1 };
}
// Calculate bounds with padding
const minVal = Math.min(...data);
const maxVal = Math.max(...data);
const range = maxVal - minVal || 1;
const padding = range * 0.1;
const yMin = minVal - padding;
const yMax = maxVal + padding;
const yRange = yMax - yMin;
// Calculate best value
const bestVal = direction === 'minimize' ? Math.min(...data) : Math.max(...data);
// Map values to SVG coordinates
const xStep = width / Math.max(data.length - 1, 1);
const mapY = (v: number) => height - ((v - yMin) / yRange) * height;
// Build path
const points = data.map((v, i) => ({
x: i * xStep,
y: mapY(v),
value: v,
}));
const pathParts = points.map((p, i) =>
i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`
);
return {
path: pathParts.join(' '),
bestY: mapY(bestVal),
points,
minVal,
maxVal,
};
}, [values, width, height, maxPoints, direction]);
if (!values || values.length === 0) {
return (
<div
className="flex items-center justify-center text-dark-500 text-xs"
style={{ width, height }}
>
No data
</div>
);
}
return (
<svg
width={width}
height={height}
className="overflow-visible"
viewBox={`0 0 ${width} ${height}`}
>
{/* Best value line */}
{showBest && bestY !== null && (
<line
x1={0}
y1={bestY}
x2={width}
y2={bestY}
stroke={bestColor}
strokeWidth={1}
strokeDasharray="2,2"
opacity={0.5}
/>
)}
{/* Main line */}
<path
d={path}
fill="none"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Gradient fill under the line */}
<defs>
<linearGradient id="sparkline-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
{points.length > 1 && (
<path
d={`${path} L ${points[points.length - 1].x} ${height} L ${points[0].x} ${height} Z`}
fill="url(#sparkline-gradient)"
/>
)}
{/* Dots at each point */}
{showDots && points.map((p, i) => (
<circle
key={i}
cx={p.x}
cy={p.y}
r={2}
fill={color}
/>
))}
{/* Last point highlight */}
{points.length > 0 && (
<circle
cx={points[points.length - 1].x}
cy={points[points.length - 1].y}
r={3}
fill={color}
stroke="white"
strokeWidth={1}
/>
)}
</svg>
);
}
/**
* ProgressRing - Circular progress indicator
*/
interface ProgressRingProps {
/** Progress percentage (0-100) */
progress: number;
/** Size in pixels */
size?: number;
/** Stroke width */
strokeWidth?: number;
/** Progress color */
color?: string;
/** Background color */
bgColor?: string;
/** Show percentage text */
showText?: boolean;
}
export function ProgressRing({
progress,
size = 32,
strokeWidth = 3,
color = '#60a5fa',
bgColor = '#374151',
showText = true,
}: ProgressRingProps) {
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const offset = circumference - (Math.min(100, Math.max(0, progress)) / 100) * circumference;
return (
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
<svg width={size} height={size} className="transform -rotate-90">
{/* Background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={bgColor}
strokeWidth={strokeWidth}
/>
{/* Progress circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-300"
/>
</svg>
{showText && (
<span
className="absolute text-xs font-medium"
style={{ color, fontSize: size * 0.25 }}
>
{Math.round(progress)}%
</span>
)}
</div>
);
}
export default ConvergenceSparkline;