/** * 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 { ConnectionStatusIndicator } from './ConnectionStatusIndicator'; import { CanvasNodeData } from '../../lib/canvas/schema'; import { validateSpec, canRunOptimization } from '../../lib/validation/specValidator'; // ============================================================================ // Drag-Drop Helpers // ============================================================================ /** Addable node types via drag-drop */ const ADDABLE_NODE_TYPES = ['designVar', 'extractor', 'objective', 'constraint'] as const; type AddableNodeType = typeof ADDABLE_NODE_TYPES[number]; function isAddableNodeType(type: string): type is AddableNodeType { return ADDABLE_NODE_TYPES.includes(type as AddableNodeType); } /** Maps canvas NodeType to spec API type */ function mapNodeTypeToSpecType(type: AddableNodeType): 'designVar' | 'extractor' | 'objective' | 'constraint' { return type; } /** Creates default data for a new node of the given type */ function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: number }): Record { const timestamp = Date.now(); switch (type) { 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, }; } } // ============================================================================ // 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(null); const reactFlowInstance = useRef(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 execution state const [isRunning, setIsRunning] = useState(false); const [isStarting, setIsStarting] = useState(false); const [bestTrial, setBestTrial] = useState(null); const [showResults, setShowResults] = useState(false); const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | 'unchecked'>('unchecked'); // Track last seen error timestamp to avoid duplicates const lastErrorTimestamp = useRef(0); // Poll optimization status useEffect(() => { if (!studyId) return; const checkStatus = async () => { try { const res = await fetch(`/api/optimization/studies/${studyId}/status`); if (res.ok) { const data = await res.json(); setIsRunning(data.status === 'running'); if (data.best_trial) { setBestTrial(data.best_trial); // Auto-show results if running or completed and not explicitly disabled if ((data.status === 'running' || data.status === 'completed') && !showResults) { setShowResults(true); } } // Handle errors from the optimization process if (data.error && data.error_timestamp && data.error_timestamp > lastErrorTimestamp.current) { lastErrorTimestamp.current = data.error_timestamp; // Classify the error based on the message let errorType: 'nx_crash' | 'solver_fail' | 'extractor_error' | 'config_error' | 'system_error' | 'unknown' = 'unknown'; const errorMsg = data.error.toLowerCase(); if (errorMsg.includes('nx') || errorMsg.includes('siemens') || errorMsg.includes('journal')) { errorType = 'nx_crash'; } else if (errorMsg.includes('solver') || errorMsg.includes('nastran') || errorMsg.includes('convergence')) { errorType = 'solver_fail'; } else if (errorMsg.includes('extractor') || errorMsg.includes('extract') || errorMsg.includes('op2')) { errorType = 'extractor_error'; } else if (errorMsg.includes('config') || errorMsg.includes('spec') || errorMsg.includes('parameter')) { errorType = 'config_error'; } else if (errorMsg.includes('system') || errorMsg.includes('permission') || errorMsg.includes('disk')) { errorType = 'system_error'; } // Generate suggestions based on error type const suggestions: string[] = []; switch (errorType) { case 'nx_crash': suggestions.push('Check if NX is running and licensed'); suggestions.push('Verify the model file is not corrupted'); suggestions.push('Try closing and reopening NX'); break; case 'solver_fail': suggestions.push('Check the mesh quality in the FEM file'); suggestions.push('Verify boundary conditions are properly defined'); suggestions.push('Review the solver settings'); break; case 'extractor_error': suggestions.push('Verify the OP2 file was created successfully'); suggestions.push('Check if the extractor type matches the analysis'); suggestions.push('For custom extractors, review the code for errors'); break; case 'config_error': suggestions.push('Run validation to check the spec'); suggestions.push('Verify all design variables have valid bounds'); break; default: suggestions.push('Check the optimization logs for more details'); } addError({ type: errorType, trial: data.current_trial, message: data.error, details: data.error_details, recoverable: errorType !== 'config_error', suggestions, timestamp: data.error_timestamp || Date.now(), }); } // Handle failed status if (data.status === 'failed' && data.error) { setIsRunning(false); } } } catch (e) { // Silent fail on polling - network issues shouldn't spam errors console.debug('Status poll failed:', e); } }; checkStatus(); const interval = setInterval(checkStatus, 3000); return () => clearInterval(interval); }, [studyId, addError]); // 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'); } setIsRunning(true); 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'); } setIsRunning(false); } 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); if (showResults && bestTrial) { return baseNodes.map(node => { const newData = { ...node.data }; // Map results to nodes if (node.type === 'designVar') { 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 && bestTrial.results[outputName] !== undefined) { newData.resultValue = bestTrial.results[outputName]; } } else if (node.type === 'constraint') { const outputName = newData.outputName; if (outputName && bestTrial.results && bestTrial.results[outputName] !== undefined) { const val = bestTrial.results[outputName]; newData.resultValue = val; // Check feasibility if (newData.operator === '<=' && newData.value !== undefined) newData.isFeasible = val <= newData.value; else if (newData.operator === '>=' && newData.value !== undefined) newData.isFeasible = val >= newData.value; else if (newData.operator === '<' && newData.value !== undefined) newData.isFeasible = val < newData.value; else if (newData.operator === '>' && newData.value !== undefined) newData.isFeasible = val > newData.value; else if (newData.operator === '==' && newData.value !== undefined) newData.isFeasible = Math.abs(val - newData.value) < 1e-6; } } else if (node.type === 'extractor') { if (newData.outputNames && newData.outputNames.length > 0 && bestTrial.results) { const firstOut = newData.outputNames[0]; if (bestTrial.results[firstOut] !== undefined) { newData.resultValue = bestTrial.results[firstOut]; } } } return { ...node, data: newData }; }); } return baseNodes; }, [spec, showResults, bestTrial]); // 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[]>(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; } // 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); try { const nodeId = await addNode(specType, 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] ); // Loading state if (showLoadingOverlay && isLoading && !spec) { return (

Loading spec...

); } // Error state if (error && !spec) { return (

Failed to load spec

{error}

{studyId && ( )}
); } // Empty state if (!spec) { return (

No spec loaded

Load a study to see its optimization configuration

); } return (
{/* Status indicators (overlay) */}
{/* WebSocket connection status */} {enableWebSocket && showConnectionStatus && (
)} {/* Loading indicator */} {isLoading && (
Syncing...
)}
{/* Error banner (overlay) */} {error && (
{error}
)} { reactFlowInstance.current = instance; }} onDragOver={onDragOver} onDrop={onDrop} onNodeClick={onNodeClick} onEdgeClick={onEdgeClick} onPaneClick={onPaneClick} nodeTypes={nodeTypes} fitView deleteKeyCode={null} // We handle delete ourselves nodesDraggable={editable} nodesConnectable={editable} elementsSelectable={true} className="bg-dark-900" > {/* Action Buttons */}
{/* Results toggle */} {bestTrial && ( )} {/* Validate button - shows validation status */} {/* Run/Stop button */} {isRunning ? ( ) : ( )}
{/* Study name badge */}
{spec.meta.study_name}
); } /** * SpecRenderer with ReactFlowProvider wrapper. * * Usage: * ```tsx * // Load spec on mount * * * // Use with already-loaded spec * const { loadSpec } = useSpecStore(); * await loadSpec('M1_Mirror/m1_mirror_flatback'); * * ``` */ export function SpecRenderer(props: SpecRendererProps) { return ( ); } export default SpecRenderer;