Files
Atomizer/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx

940 lines
31 KiB
TypeScript
Raw Normal View History

/**
* SpecRenderer - ReactFlow canvas that renders from AtomizerSpec v2.0
*
* This component replaces the legacy canvas approach with a spec-driven architecture:
* - Reads from useSpecStore instead of useCanvasStore
* - Converts spec to ReactFlow nodes/edges using spec converters
* - All changes flow through the spec store and sync with backend
* - Supports WebSocket real-time updates
*
* P2.7-P2.10: SpecRenderer component with node/edge/selection handling
*/
import { useCallback, useRef, useEffect, useMemo, useState, DragEvent } from 'react';
import { Play, Square, Loader2, Eye, EyeOff, CheckCircle, AlertCircle } from 'lucide-react';
import ReactFlow, {
Background,
Controls,
MiniMap,
ReactFlowProvider,
ReactFlowInstance,
Edge,
Node,
NodeChange,
EdgeChange,
Connection,
applyNodeChanges,
} from 'reactflow';
import 'reactflow/dist/style.css';
import { nodeTypes } from './nodes';
import { specToNodes, specToEdges } from '../../lib/spec';
import {
useSpecStore,
useSpec,
useSpecLoading,
useSpecError,
useSelectedNodeId,
useSelectedEdgeId,
} from '../../hooks/useSpecStore';
import { useSpecWebSocket } from '../../hooks/useSpecWebSocket';
import { usePanelStore } from '../../hooks/usePanelStore';
import { useOptimizationStream } from '../../hooks/useOptimizationStream';
import { ConnectionStatusIndicator } from './ConnectionStatusIndicator';
import { ProgressRing } from './visualization/ConvergenceSparkline';
import { CanvasNodeData } from '../../lib/canvas/schema';
import { validateSpec, canRunOptimization } from '../../lib/validation/specValidator';
// ============================================================================
// Drag-Drop Helpers
// ============================================================================
import { SINGLETON_TYPES } from './palette/NodePalette';
/** All node types that can be added via drag-drop */
const ADDABLE_NODE_TYPES = ['model', 'solver', 'designVar', 'extractor', 'objective', 'constraint', 'algorithm', 'surrogate'] as const;
type AddableNodeType = typeof ADDABLE_NODE_TYPES[number];
function isAddableNodeType(type: string): type is AddableNodeType {
return ADDABLE_NODE_TYPES.includes(type as AddableNodeType);
}
/** Check if a node type is a singleton (only one allowed) */
function isSingletonType(type: string): boolean {
return SINGLETON_TYPES.includes(type as typeof SINGLETON_TYPES[number]);
}
/** Maps canvas NodeType to spec API type */
function mapNodeTypeToSpecType(type: AddableNodeType): 'designVar' | 'extractor' | 'objective' | 'constraint' | 'model' | 'solver' | 'algorithm' | 'surrogate' {
return type;
}
/** Creates default data for a new node of the given type */
function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: number }): Record<string, unknown> {
const timestamp = Date.now();
switch (type) {
case 'model':
return {
name: 'Model',
sim: {
path: '',
solver: 'nastran',
},
canvas_position: position,
};
case 'solver':
return {
name: 'Solver',
engine: 'nxnastran',
solution_type: 'SOL101',
canvas_position: position,
};
case 'designVar':
return {
name: `variable_${timestamp}`,
expression_name: `expr_${timestamp}`,
type: 'continuous',
bounds: { min: 0, max: 1 },
baseline: 0.5,
enabled: true,
canvas_position: position,
};
case 'extractor':
return {
name: `extractor_${timestamp}`,
type: 'custom_function', // Must be valid ExtractorType
builtin: false,
enabled: true,
// Custom function extractors need a function definition
function: {
name: 'extract',
source_code: `def extract(op2_path: str, config: dict = None) -> dict:
"""
Custom extractor function.
Args:
op2_path: Path to the OP2 results file
config: Optional configuration dict
Returns:
Dictionary with extracted values
"""
# TODO: Implement extraction logic
return {'value': 0.0}
`,
},
outputs: [{ name: 'value', metric: 'custom' }],
canvas_position: position,
};
case 'objective':
return {
name: `objective_${timestamp}`,
direction: 'minimize',
weight: 1.0,
// Source is required - use placeholder that user must configure
source: {
extractor_id: 'ext_001', // Placeholder - user needs to configure
output_name: 'value',
},
canvas_position: position,
};
case 'constraint':
return {
name: `constraint_${timestamp}`,
type: 'hard', // Must be 'hard' or 'soft' (field is 'type' not 'constraint_type')
operator: '<=',
threshold: 1.0, // Field is 'threshold' not 'limit'
// Source is required
source: {
extractor_id: 'ext_001', // Placeholder - user needs to configure
output_name: 'value',
},
enabled: true,
canvas_position: position,
};
case 'algorithm':
return {
name: 'Algorithm',
type: 'TPE',
budget: {
max_trials: 100,
},
canvas_position: position,
};
case 'surrogate':
return {
name: 'Surrogate',
enabled: false,
model_type: 'MLP',
min_trials: 20,
canvas_position: position,
};
}
}
// ============================================================================
// Component Props
// ============================================================================
interface SpecRendererProps {
/**
* Optional study ID to load on mount.
* If not provided, assumes spec is already loaded in the store.
*/
studyId?: string;
/**
* Callback when study changes (for URL updates)
*/
onStudyChange?: (studyId: string) => void;
/**
* Show loading overlay while spec is loading
*/
showLoadingOverlay?: boolean;
/**
* Enable/disable editing (drag, connect, delete)
*/
editable?: boolean;
/**
* Enable real-time WebSocket sync (default: true)
*/
enableWebSocket?: boolean;
/**
* Show connection status indicator (default: true when WebSocket enabled)
*/
showConnectionStatus?: boolean;
}
function SpecRendererInner({
studyId,
onStudyChange,
showLoadingOverlay = true,
editable = true,
enableWebSocket = true,
showConnectionStatus = true,
}: SpecRendererProps) {
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const reactFlowInstance = useRef<ReactFlowInstance | null>(null);
// Spec store state and actions
const spec = useSpec();
const isLoading = useSpecLoading();
const error = useSpecError();
const selectedNodeId = useSelectedNodeId();
const selectedEdgeId = useSelectedEdgeId();
const {
loadSpec,
selectNode,
selectEdge,
clearSelection,
updateNodePosition,
addNode,
addEdge,
removeEdge,
removeNode,
setError,
} = useSpecStore();
// WebSocket for real-time sync
const storeStudyId = useSpecStore((s) => s.studyId);
const wsStudyId = enableWebSocket ? storeStudyId : null;
const { status: wsStatus } = useSpecWebSocket(wsStudyId);
// Panel store for validation and error panels
const { setValidationData, addError, openPanel } = usePanelStore();
// Optimization WebSocket stream for real-time updates
const {
status: optimizationStatus,
progress: wsProgress,
bestTrial: wsBestTrial,
recentTrials,
} = useOptimizationStream(studyId, {
autoReportErrors: true,
onTrialComplete: (trial) => {
console.log('[SpecRenderer] Trial completed:', trial.trial_number);
},
onNewBest: (best) => {
console.log('[SpecRenderer] New best found:', best.value);
setShowResults(true); // Auto-show results when new best found
},
});
// Optimization execution state
const isRunning = optimizationStatus === 'running';
const [isStarting, setIsStarting] = useState(false);
const [showResults, setShowResults] = useState(false);
const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | 'unchecked'>('unchecked');
// Build trial history for sparklines (extract objective values from recent trials)
const trialHistory = useMemo(() => {
const history: Record<string, number[]> = {};
for (const trial of recentTrials) {
// Map objective values - assumes single objective for now
if (trial.objective !== null) {
const key = 'primary';
if (!history[key]) history[key] = [];
history[key].push(trial.objective);
}
// Could also extract individual params/results for multi-objective
}
// Reverse so oldest is first (for sparkline)
for (const key of Object.keys(history)) {
history[key].reverse();
}
return history;
}, [recentTrials]);
// Build best trial data for node display
const bestTrial = useMemo((): {
trial_number: number;
objective: number;
design_variables: Record<string, number>;
results: Record<string, number>;
} | null => {
if (!wsBestTrial) return null;
return {
trial_number: wsBestTrial.trial_number,
objective: wsBestTrial.value,
design_variables: wsBestTrial.params,
results: { primary: wsBestTrial.value, ...wsBestTrial.params },
};
}, [wsBestTrial]);
// Note: Polling removed - now using WebSocket via useOptimizationStream hook
// The hook handles: status updates, best trial updates, error reporting
// Validate the spec and show results in panel
const handleValidate = useCallback(() => {
if (!spec) return;
const result = validateSpec(spec);
setValidationData(result);
setValidationStatus(result.valid ? 'valid' : 'invalid');
// Auto-open validation panel if there are issues
if (!result.valid || result.warnings.length > 0) {
openPanel('validation');
}
return result;
}, [spec, setValidationData, openPanel]);
const handleRun = async () => {
if (!studyId || !spec) return;
// Validate before running
const validation = handleValidate();
if (!validation || !validation.valid) {
// Show validation panel with errors
return;
}
// Also do a quick sanity check
const { canRun, reason } = canRunOptimization(spec);
if (!canRun) {
addError({
type: 'config_error',
message: reason || 'Cannot run optimization',
recoverable: false,
suggestions: ['Check the validation panel for details'],
timestamp: Date.now(),
});
return;
}
setIsStarting(true);
try {
const res = await fetch(`/api/optimization/studies/${studyId}/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trials: spec?.optimization?.budget?.max_trials || 50 })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Failed to start');
}
// isRunning is now derived from WebSocket state (optimizationStatus === 'running')
setValidationStatus('unchecked'); // Clear validation status when running
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to start optimization';
setError(errorMessage);
// Also add to error panel for persistence
addError({
type: 'system_error',
message: errorMessage,
recoverable: true,
suggestions: ['Check if the backend is running', 'Verify the study configuration'],
timestamp: Date.now(),
});
} finally {
setIsStarting(false);
}
};
const handleStop = async () => {
if (!studyId) return;
try {
const res = await fetch(`/api/optimization/studies/${studyId}/stop`, { method: 'POST' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to stop');
}
// isRunning will update via WebSocket when optimization actually stops
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to stop optimization';
setError(errorMessage);
addError({
type: 'system_error',
message: errorMessage,
recoverable: false,
suggestions: ['The optimization may still be running in the background'],
timestamp: Date.now(),
});
}
};
// Load spec on mount if studyId provided
useEffect(() => {
if (studyId) {
loadSpec(studyId).then(() => {
if (onStudyChange) {
onStudyChange(studyId);
}
});
}
}, [studyId, loadSpec, onStudyChange]);
// Convert spec to ReactFlow nodes
const nodes = useMemo(() => {
const baseNodes = specToNodes(spec);
// Always map nodes to include history for sparklines (even if not showing results)
return baseNodes.map(node => {
// Create a mutable copy with explicit any type for dynamic property assignment
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newData: any = { ...node.data };
// Add history for sparklines on objective nodes
if (node.type === 'objective') {
newData.history = trialHistory['primary'] || [];
}
// Map results to nodes when showing results
if (showResults && bestTrial) {
if (node.type === 'designVar' && newData.expressionName) {
const val = bestTrial.design_variables?.[newData.expressionName];
if (val !== undefined) newData.resultValue = val;
} else if (node.type === 'objective') {
const outputName = newData.outputName;
if (outputName && bestTrial.results?.[outputName] !== undefined) {
newData.resultValue = bestTrial.results[outputName];
}
} else if (node.type === 'constraint') {
const outputName = newData.outputName;
if (outputName && bestTrial.results?.[outputName] !== undefined) {
const val = bestTrial.results[outputName];
newData.resultValue = val;
// Check feasibility
const op = newData.operator;
const threshold = newData.value;
if (op === '<=' && threshold !== undefined) newData.isFeasible = val <= threshold;
else if (op === '>=' && threshold !== undefined) newData.isFeasible = val >= threshold;
else if (op === '<' && threshold !== undefined) newData.isFeasible = val < threshold;
else if (op === '>' && threshold !== undefined) newData.isFeasible = val > threshold;
else if (op === '==' && threshold !== undefined) newData.isFeasible = Math.abs(val - threshold) < 1e-6;
}
} else if (node.type === 'extractor') {
const outputNames = newData.outputNames;
if (outputNames && outputNames.length > 0 && bestTrial.results) {
const firstOut = outputNames[0];
if (bestTrial.results[firstOut] !== undefined) {
newData.resultValue = bestTrial.results[firstOut];
}
}
}
}
return { ...node, data: newData };
});
}, [spec, showResults, bestTrial, trialHistory]);
// Convert spec to ReactFlow edges with selection styling
const edges = useMemo(() => {
const baseEdges = specToEdges(spec);
return baseEdges.map((edge) => ({
...edge,
style: {
stroke: edge.id === selectedEdgeId ? '#60a5fa' : '#6b7280',
strokeWidth: edge.id === selectedEdgeId ? 3 : 2,
},
animated: edge.id === selectedEdgeId,
}));
}, [spec, selectedEdgeId]);
// Track node positions for change handling
const nodesRef = useRef<Node<CanvasNodeData>[]>(nodes);
useEffect(() => {
nodesRef.current = nodes;
}, [nodes]);
// Track local node state for smooth dragging
const [localNodes, setLocalNodes] = useState(nodes);
// Sync local nodes with spec-derived nodes when spec changes
useEffect(() => {
setLocalNodes(nodes);
}, [nodes]);
// Handle node position changes
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
if (!editable) return;
// Apply changes to local state for smooth dragging
setLocalNodes((nds) => applyNodeChanges(changes, nds));
// Handle position changes - save to spec when drag ends
for (const change of changes) {
if (change.type === 'position' && change.position && change.dragging === false) {
// Dragging ended - update spec
updateNodePosition(change.id, {
x: change.position.x,
y: change.position.y,
});
}
}
},
[editable, updateNodePosition]
);
// Handle edge changes (deletion)
const onEdgesChange = useCallback(
(changes: EdgeChange[]) => {
if (!editable) return;
for (const change of changes) {
if (change.type === 'remove') {
// Find the edge being removed
const edge = edges.find((e) => e.id === change.id);
if (edge) {
removeEdge(edge.source, edge.target).catch((err) => {
console.error('Failed to remove edge:', err);
setError(err.message);
});
}
}
}
},
[editable, edges, removeEdge, setError]
);
// Handle new connections
const onConnect = useCallback(
(connection: Connection) => {
if (!editable) return;
if (!connection.source || !connection.target) return;
addEdge(connection.source, connection.target).catch((err) => {
console.error('Failed to add edge:', err);
setError(err.message);
});
},
[editable, addEdge, setError]
);
// Handle node clicks for selection
const onNodeClick = useCallback(
(_: React.MouseEvent, node: { id: string }) => {
selectNode(node.id);
},
[selectNode]
);
// Handle edge clicks for selection
const onEdgeClick = useCallback(
(_: React.MouseEvent, edge: Edge) => {
selectEdge(edge.id);
},
[selectEdge]
);
// Handle pane clicks to clear selection
const onPaneClick = useCallback(() => {
clearSelection();
}, [clearSelection]);
// Keyboard handler for Delete/Backspace
useEffect(() => {
if (!editable) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Delete' || event.key === 'Backspace') {
const target = event.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return;
}
// Delete selected edge first
if (selectedEdgeId) {
const edge = edges.find((e) => e.id === selectedEdgeId);
if (edge) {
removeEdge(edge.source, edge.target).catch((err) => {
console.error('Failed to delete edge:', err);
setError(err.message);
});
}
return;
}
// Delete selected node
if (selectedNodeId) {
// Don't allow deleting synthetic nodes (model, solver, optimization)
if (['model', 'solver', 'optimization', 'surrogate'].includes(selectedNodeId)) {
return;
}
removeNode(selectedNodeId).catch((err) => {
console.error('Failed to delete node:', err);
setError(err.message);
});
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [editable, selectedNodeId, selectedEdgeId, edges, removeNode, removeEdge, setError]);
// =========================================================================
// Drag-Drop Handlers
// =========================================================================
const onDragOver = useCallback(
(event: DragEvent) => {
if (!editable) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
},
[editable]
);
const onDrop = useCallback(
async (event: DragEvent) => {
if (!editable || !reactFlowInstance.current) return;
event.preventDefault();
const type = event.dataTransfer.getData('application/reactflow');
if (!type || !isAddableNodeType(type)) {
console.warn('Invalid or non-addable node type dropped:', type);
return;
}
// Check if this is a singleton type that already exists
if (isSingletonType(type)) {
const existingNode = localNodes.find(n => n.type === type);
if (existingNode) {
// Select the existing node instead of creating a duplicate
selectNode(existingNode.id);
// Show a toast notification would be nice here
console.log(`${type} already exists - selected existing node`);
return;
}
}
// Convert screen position to flow position
const position = reactFlowInstance.current.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
// Create default data for the node
const nodeData = getDefaultNodeData(type, position);
const specType = mapNodeTypeToSpecType(type);
// For structural types (model, solver, algorithm, surrogate), these are
// part of the spec structure rather than array items. Handle differently.
const structuralTypes = ['model', 'solver', 'algorithm', 'surrogate'];
if (structuralTypes.includes(type)) {
// These nodes are derived from spec structure - they shouldn't be "added"
// They already exist if the spec has that section configured
console.log(`${type} is a structural node - configure via spec directly`);
setError(`${type} nodes are configured via the spec. Use the config panel to edit.`);
return;
}
try {
const nodeId = await addNode(specType as 'designVar' | 'extractor' | 'objective' | 'constraint', nodeData);
// Select the newly created node
selectNode(nodeId);
} catch (err) {
console.error('Failed to add node:', err);
setError(err instanceof Error ? err.message : 'Failed to add node');
}
},
[editable, addNode, selectNode, setError, localNodes]
);
// Loading state
if (showLoadingOverlay && isLoading && !spec) {
return (
<div className="flex-1 flex items-center justify-center bg-dark-900">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto mb-4" />
<p className="text-dark-400">Loading spec...</p>
</div>
</div>
);
}
// Error state
if (error && !spec) {
return (
<div className="flex-1 flex items-center justify-center bg-dark-900">
<div className="text-center max-w-md">
<div className="w-12 h-12 rounded-full bg-red-900/50 flex items-center justify-center mx-auto mb-4">
<svg className="w-6 h-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className="text-lg font-medium text-white mb-2">Failed to load spec</h3>
<p className="text-dark-400 mb-4">{error}</p>
{studyId && (
<button
onClick={() => loadSpec(studyId)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-500 transition-colors"
>
Retry
</button>
)}
</div>
</div>
);
}
// Empty state
if (!spec) {
return (
<div className="flex-1 flex items-center justify-center bg-dark-900">
<div className="text-center">
<p className="text-dark-400">No spec loaded</p>
<p className="text-dark-500 text-sm mt-2">Load a study to see its optimization configuration</p>
</div>
</div>
);
}
return (
<div className="w-full h-full relative" ref={reactFlowWrapper}>
{/* Status indicators (overlay) */}
<div className="absolute top-4 right-4 z-20 flex items-center gap-2">
{/* WebSocket connection status */}
{enableWebSocket && showConnectionStatus && (
<div className="px-3 py-1.5 bg-dark-800/90 backdrop-blur rounded-lg border border-dark-600">
<ConnectionStatusIndicator status={wsStatus} />
</div>
)}
{/* Loading indicator */}
{isLoading && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-800/90 backdrop-blur rounded-lg border border-dark-600">
<div className="animate-spin w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full" />
<span className="text-xs text-dark-300">Syncing...</span>
</div>
)}
</div>
{/* Error banner (overlay) */}
{error && (
<div className="absolute top-4 left-4 right-20 z-20 px-4 py-2 bg-red-900/90 backdrop-blur border border-red-700 text-red-200 rounded-lg text-sm flex justify-between items-center">
<span>{error}</span>
<button
onClick={() => setError(null)}
className="text-red-400 hover:text-red-200 ml-2"
>
×
</button>
</div>
)}
<ReactFlow
nodes={localNodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onInit={(instance) => {
reactFlowInstance.current = instance;
// Auto-fit view on init with padding
setTimeout(() => instance.fitView({ padding: 0.2, duration: 300 }), 100);
}}
onDragOver={onDragOver}
onDrop={onDrop}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
onPaneClick={onPaneClick}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2, includeHiddenNodes: false }}
deleteKeyCode={null} // We handle delete ourselves
nodesDraggable={editable}
nodesConnectable={editable}
elementsSelectable={true}
className="bg-dark-900"
>
<Background color="#374151" gap={20} />
<Controls className="!bg-dark-800 !border-dark-600 !rounded-lg [&>button]:!bg-dark-700 [&>button]:!border-dark-600 [&>button]:!fill-dark-300 [&>button:hover]:!bg-dark-600" />
<MiniMap
className="!bg-dark-800 !border-dark-600 !rounded-lg"
nodeColor="#4B5563"
maskColor="rgba(0, 0, 0, 0.5)"
/>
</ReactFlow>
{/* Action Buttons */}
<div className="absolute bottom-4 right-4 z-10 flex gap-2">
{/* Results toggle */}
{bestTrial && (
<button
onClick={() => setShowResults(!showResults)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors border ${
showResults
? 'bg-primary-600/90 text-white border-primary-500 hover:bg-primary-500'
: 'bg-dark-800 text-dark-300 border-dark-600 hover:text-white hover:border-dark-500'
}`}
title={showResults ? "Hide Results" : "Show Best Trial Results"}
>
{showResults ? <Eye size={16} /> : <EyeOff size={16} />}
<span className="text-sm font-medium">Results</span>
</button>
)}
{/* Validate button - shows validation status */}
<button
onClick={handleValidate}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors border ${
validationStatus === 'valid'
? 'bg-green-600/20 text-green-400 border-green-500/50 hover:bg-green-600/30'
: validationStatus === 'invalid'
? 'bg-red-600/20 text-red-400 border-red-500/50 hover:bg-red-600/30'
: 'bg-dark-800 text-dark-300 border-dark-600 hover:text-white hover:border-dark-500'
}`}
title="Validate spec before running"
>
{validationStatus === 'valid' ? (
<CheckCircle size={16} />
) : validationStatus === 'invalid' ? (
<AlertCircle size={16} />
) : (
<CheckCircle size={16} />
)}
<span className="text-sm font-medium">Validate</span>
</button>
{/* Run/Stop button */}
{isRunning ? (
<button
onClick={handleStop}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-500 shadow-lg transition-colors font-medium"
>
<Square size={16} fill="currentColor" />
Stop
</button>
) : (
<button
onClick={handleRun}
disabled={isStarting || validationStatus === 'invalid'}
className={`flex items-center gap-2 px-4 py-2 rounded-lg shadow-lg transition-colors font-medium ${
validationStatus === 'invalid'
? 'bg-dark-700 text-dark-400 cursor-not-allowed'
: 'bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed'
}`}
title={validationStatus === 'invalid' ? 'Fix validation errors first' : 'Start optimization'}
>
{isStarting ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Play size={16} fill="currentColor" />
)}
Run
</button>
)}
</div>
{/* Study name badge */}
<div className="absolute bottom-4 left-4 z-10 px-3 py-1.5 bg-dark-800/90 backdrop-blur rounded-lg border border-dark-600">
<span className="text-sm text-dark-300">{spec.meta.study_name}</span>
</div>
{/* Progress indicator when running */}
{isRunning && wsProgress && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-3 px-4 py-2 bg-dark-800/95 backdrop-blur rounded-lg border border-dark-600 shadow-lg">
<ProgressRing
progress={wsProgress.percentage}
size={36}
strokeWidth={3}
color="#10b981"
/>
<div className="flex flex-col">
<span className="text-sm font-medium text-white">
Trial {wsProgress.current} / {wsProgress.total}
</span>
<span className="text-xs text-dark-400">
{wsProgress.fea_count > 0 && `${wsProgress.fea_count} FEA`}
{wsProgress.fea_count > 0 && wsProgress.nn_count > 0 && ' + '}
{wsProgress.nn_count > 0 && `${wsProgress.nn_count} NN`}
{wsProgress.fea_count === 0 && wsProgress.nn_count === 0 && 'Running...'}
</span>
</div>
{wsBestTrial && (
<div className="flex flex-col border-l border-dark-600 pl-3 ml-1">
<span className="text-xs text-dark-400">Best</span>
<span className="text-sm font-medium text-emerald-400">
{typeof wsBestTrial.value === 'number'
? wsBestTrial.value.toFixed(4)
: wsBestTrial.value}
</span>
</div>
)}
</div>
)}
</div>
);
}
/**
* SpecRenderer with ReactFlowProvider wrapper.
*
* Usage:
* ```tsx
* // Load spec on mount
* <SpecRenderer studyId="M1_Mirror/m1_mirror_flatback" />
*
* // Use with already-loaded spec
* const { loadSpec } = useSpecStore();
* await loadSpec('M1_Mirror/m1_mirror_flatback');
* <SpecRenderer />
* ```
*/
export function SpecRenderer(props: SpecRendererProps) {
return (
<ReactFlowProvider>
<SpecRendererInner {...props} />
</ReactFlowProvider>
);
}
export default SpecRenderer;