/** * 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 { 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(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 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 = {}; 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; results: Record; } | 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[]>(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 (

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; // 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" > {/* Action Buttons */}
{/* Results toggle */} {bestTrial && ( )} {/* Validate button - shows validation status */} {/* Run/Stop button */} {isRunning ? ( ) : ( )}
{/* Study name badge */}
{spec.meta.study_name}
{/* Progress indicator when running */} {isRunning && wsProgress && (
Trial {wsProgress.current} / {wsProgress.total} {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...'}
{wsBestTrial && (
Best {typeof wsBestTrial.value === 'number' ? wsBestTrial.value.toFixed(4) : wsBestTrial.value}
)}
)}
); } /** * 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;