1013 lines
35 KiB
TypeScript
1013 lines
35 KiB
TypeScript
/**
|
||
* 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;
|