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
241 lines
5.9 KiB
TypeScript
241 lines
5.9 KiB
TypeScript
/**
|
|
* 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;
|