feat(canvas): Studio Enhancement Phase 1 & 2 - v2.0 architecture and file structure
Phase 1 - Foundation:
- Add NodeConfigPanelV2 using useSpecStore for AtomizerSpec v2.0 mode
- Deprecate AtomizerCanvas and useCanvasStore with migration docs
- Add VITE_USE_LEGACY_CANVAS env var for emergency fallback
- Enhance NodePalette with collapse support, filtering, exports
- Add drag-drop support to SpecRenderer with default node data
- Setup test infrastructure (Vitest + Playwright configs)
- Add useSpecStore unit tests (15 tests)
Phase 2 - File Structure & Model:
- Create FileStructurePanel with tree view of study files
- Add ModelNodeV2 with collapsible file dependencies
- Add tabbed left sidebar (Components/Files tabs)
- Add GET /api/files/structure/{study_id} backend endpoint
- Auto-expand 1_setup folders in file tree
- Show model file introspection with solver type and expressions
Technical:
- All TypeScript checks pass
- All 15 unit tests pass
- Production build successful
This commit is contained in:
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* 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<string, unknown> {
|
||||
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<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,
|
||||
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<Node<CanvasNodeData>[]>(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 (
|
||||
<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={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onInit={(instance) => {
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</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;
|
||||
Reference in New Issue
Block a user