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

1013 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
updateNode,
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;
const classify = (id: string): string => {
if (id === 'model' || id === 'solver' || id === 'algorithm' || id === 'surrogate') return id;
const prefix = id.split('_')[0];
if (prefix === 'dv') return 'designVar';
if (prefix === 'ext') return 'extractor';
if (prefix === 'obj') return 'objective';
if (prefix === 'con') return 'constraint';
return 'unknown';
};
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) continue;
const sourceType = classify(edge.source);
const targetType = classify(edge.target);
// First remove the visual edge
removeEdge(edge.source, edge.target).catch((err) => {
console.error('Failed to remove edge:', err);
setError(err.message);
});
// Option A truth model: if we removed Extractor -> Objective/Constraint,
// clear the target's source to avoid stale runnable config.
if (sourceType === 'extractor' && (targetType === 'objective' || targetType === 'constraint')) {
updateNode(edge.target, {
// Setting to an empty object would violate schema; clear to placeholder
// and let validation catch missing wiring.
source: { extractor_id: '', output_name: '' },
}).catch((err) => {
console.error('Failed to clear source on node:', err);
setError(err.message);
});
}
}
}
},
[editable, edges, removeEdge, setError, updateNode]
);
// Handle new connections
const onConnect = useCallback(
async (connection: Connection) => {
if (!editable) return;
if (!connection.source || !connection.target) return;
const sourceId = connection.source;
const targetId = connection.target;
// Helper: classify nodes by ID (synthetic vs spec-backed)
const classify = (id: string): string => {
if (id === 'model' || id === 'solver' || id === 'algorithm' || id === 'surrogate') return id;
const prefix = id.split('_')[0];
if (prefix === 'dv') return 'designVar';
if (prefix === 'ext') return 'extractor';
if (prefix === 'obj') return 'objective';
if (prefix === 'con') return 'constraint';
return 'unknown';
};
const sourceType = classify(sourceId);
const targetType = classify(targetId);
try {
// Always persist the visual edge (for now)
await addEdge(sourceId, targetId);
// Option A truth model: objective/constraint source is the real linkage.
// When user connects Extractor -> Objective/Constraint, update *.source accordingly.
if (spec && sourceType === 'extractor' && (targetType === 'objective' || targetType === 'constraint')) {
const ext = spec.extractors.find((e) => e.id === sourceId);
// Choose a sensible default output:
// - prefer 'value' if present
// - else if only one output, use it
// - else use first output
const outputs = ext?.outputs || [];
const preferred = outputs.find((o) => o.name === 'value')?.name;
const outputName =
preferred || (outputs.length === 1 ? outputs[0].name : outputs.length > 0 ? outputs[0].name : 'value');
if (targetType === 'objective') {
await updateNode(targetId, {
source: { extractor_id: sourceId, output_name: outputName },
});
} else {
await updateNode(targetId, {
source: { extractor_id: sourceId, output_name: outputName },
});
}
}
} catch (err) {
console.error('Failed to add connection:', err);
setError(err instanceof Error ? err.message : 'Failed to add connection');
}
},
[editable, addEdge, setError, spec, updateNode]
);
// 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;