/** * 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, DragEvent } from 'react'; import ReactFlow, { Background, Controls, MiniMap, ReactFlowProvider, ReactFlowInstance, Edge, Node, NodeChange, EdgeChange, Connection, } 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 { ConnectionStatusIndicator } from './ConnectionStatusIndicator'; import { CanvasNodeData } from '../../lib/canvas/schema'; // ============================================================================ // 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', enabled: true, canvas_position: position, }; case 'objective': return { name: `objective_${timestamp}`, direction: 'minimize', weight: 1.0, source_extractor_id: null, source_output: null, canvas_position: position, }; case 'constraint': return { name: `constraint_${timestamp}`, type: 'upper', limit: 1.0, source_extractor_id: null, source_output: null, 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); // 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(() => { return specToNodes(spec); }, [spec]); // 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]); // Handle node position changes const onNodesChange = useCallback( (changes: NodeChange[]) => { if (!editable) return; // Handle position changes 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" > {/* 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;