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:
@@ -1,3 +1,19 @@
|
||||
/**
|
||||
* @deprecated This component is deprecated as of January 2026.
|
||||
* Use SpecRenderer instead, which works with AtomizerSpec v2.0.
|
||||
*
|
||||
* Migration guide:
|
||||
* - Replace <AtomizerCanvas studyId="..." /> with <SpecRenderer studyId="..." />
|
||||
* - Use useSpecStore instead of useCanvasStore for state management
|
||||
* - Spec mode uses atomizer_spec.json instead of optimization_config.json
|
||||
*
|
||||
* This component is kept for emergency fallback only. Enable legacy mode
|
||||
* by setting VITE_USE_LEGACY_CANVAS=true in your environment.
|
||||
*
|
||||
* @see SpecRenderer for the new implementation
|
||||
* @see useSpecStore for the new state management
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useState, useEffect, DragEvent } from 'react';
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
@@ -8,7 +24,6 @@ import ReactFlow, {
|
||||
Edge,
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
import { MessageCircle, Plug, X, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { nodeTypes } from './nodes';
|
||||
import { NodePalette } from './palette/NodePalette';
|
||||
@@ -16,15 +31,21 @@ import { NodeConfigPanel } from './panels/NodeConfigPanel';
|
||||
import { ValidationPanel } from './panels/ValidationPanel';
|
||||
import { ExecuteDialog } from './panels/ExecuteDialog';
|
||||
import { useCanvasStore } from '../../hooks/useCanvasStore';
|
||||
import { useCanvasChat } from '../../hooks/useCanvasChat';
|
||||
import { NodeType } from '../../lib/canvas/schema';
|
||||
import { ChatPanel } from './panels/ChatPanel';
|
||||
|
||||
function CanvasFlow() {
|
||||
interface CanvasFlowProps {
|
||||
initialStudyId?: string;
|
||||
initialStudyPath?: string;
|
||||
onStudyChange?: (studyId: string) => void;
|
||||
}
|
||||
|
||||
function CanvasFlow({ initialStudyId, initialStudyPath, onStudyChange }: CanvasFlowProps) {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const reactFlowInstance = useRef<ReactFlowInstance | null>(null);
|
||||
const [showExecuteDialog, setShowExecuteDialog] = useState(false);
|
||||
const [showChat, setShowChat] = useState(false);
|
||||
const [studyId, setStudyId] = useState<string | null>(initialStudyId || null);
|
||||
const [studyPath, setStudyPath] = useState<string | null>(initialStudyPath || null);
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
|
||||
const {
|
||||
nodes,
|
||||
@@ -41,32 +62,38 @@ function CanvasFlow() {
|
||||
validation,
|
||||
validate,
|
||||
toIntent,
|
||||
loadFromConfig,
|
||||
} = useCanvasStore();
|
||||
|
||||
const [chatError, setChatError] = useState<string | null>(null);
|
||||
const [isLoadingStudy, setIsLoadingStudy] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
messages,
|
||||
isThinking,
|
||||
isExecuting,
|
||||
isConnected,
|
||||
executeIntent,
|
||||
validateIntent,
|
||||
analyzeIntent,
|
||||
sendMessage,
|
||||
} = useCanvasChat({
|
||||
onError: (error) => {
|
||||
console.error('Canvas chat error:', error);
|
||||
setChatError(error);
|
||||
},
|
||||
});
|
||||
// Load a study config into the canvas
|
||||
const handleLoadStudy = async () => {
|
||||
if (!studyId) return;
|
||||
|
||||
const handleReconnect = useCallback(() => {
|
||||
setChatError(null);
|
||||
// Force refresh chat connection by toggling panel
|
||||
setShowChat(false);
|
||||
setTimeout(() => setShowChat(true), 100);
|
||||
}, []);
|
||||
setIsLoadingStudy(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/optimization/studies/${encodeURIComponent(studyId)}/config`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load study: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
loadFromConfig(data.config);
|
||||
setStudyPath(data.path);
|
||||
|
||||
// Notify parent of study change (for URL updates)
|
||||
if (onStudyChange) {
|
||||
onStudyChange(studyId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load study:', error);
|
||||
setLoadError(error instanceof Error ? error.message : 'Failed to load study');
|
||||
} finally {
|
||||
setIsLoadingStudy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = useCallback((event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -80,7 +107,6 @@ function CanvasFlow() {
|
||||
const type = event.dataTransfer.getData('application/reactflow') as NodeType;
|
||||
if (!type || !reactFlowInstance.current) return;
|
||||
|
||||
// screenToFlowPosition expects screen coordinates directly
|
||||
const position = reactFlowInstance.current.screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
@@ -114,7 +140,6 @@ function CanvasFlow() {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
// Don't delete if focus is on an input
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
@@ -128,22 +153,7 @@ function CanvasFlow() {
|
||||
}, [deleteSelected]);
|
||||
|
||||
const handleValidate = () => {
|
||||
const result = validate();
|
||||
if (result.valid) {
|
||||
// Also send to Claude for intelligent feedback
|
||||
const intent = toIntent();
|
||||
validateIntent(intent);
|
||||
setShowChat(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnalyze = () => {
|
||||
const result = validate();
|
||||
if (result.valid) {
|
||||
const intent = toIntent();
|
||||
analyzeIntent(intent);
|
||||
setShowChat(true);
|
||||
}
|
||||
validate();
|
||||
};
|
||||
|
||||
const handleExecuteClick = () => {
|
||||
@@ -153,12 +163,43 @@ function CanvasFlow() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async (studyName: string, autoRun: boolean, _mode: 'create' | 'update', _existingStudyId?: string) => {
|
||||
const intent = toIntent();
|
||||
// For now, both modes use the same executeIntent - backend will handle the mode distinction
|
||||
await executeIntent(intent, studyName, autoRun);
|
||||
setShowExecuteDialog(false);
|
||||
setShowChat(true);
|
||||
const handleExecute = async (studyName: string, autoRun: boolean, mode: 'create' | 'update', existingStudyId?: string) => {
|
||||
setIsExecuting(true);
|
||||
try {
|
||||
const intent = toIntent();
|
||||
|
||||
// Call API to create/update study from intent
|
||||
const endpoint = mode === 'update' && existingStudyId
|
||||
? `/api/optimization/studies/${encodeURIComponent(existingStudyId)}/update-from-intent`
|
||||
: '/api/optimization/studies/create-from-intent';
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
study_name: studyName,
|
||||
intent,
|
||||
auto_run: autoRun,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || `Failed to ${mode} study`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setStudyId(studyName);
|
||||
setStudyPath(result.path);
|
||||
|
||||
console.log(`Study ${mode}d:`, result);
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${mode} study:`, error);
|
||||
setLoadError(error instanceof Error ? error.message : `Failed to ${mode} study`);
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
setShowExecuteDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -168,6 +209,37 @@ function CanvasFlow() {
|
||||
|
||||
{/* Center: Canvas */}
|
||||
<div className="flex-1 relative" ref={reactFlowWrapper}>
|
||||
{/* Study Context Bar */}
|
||||
<div className="absolute top-4 left-4 right-4 z-10 flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={studyId || ''}
|
||||
onChange={(e) => setStudyId(e.target.value || null)}
|
||||
placeholder="Study ID (e.g., M1_Mirror/m1_mirror_flatback)"
|
||||
className="flex-1 max-w-md px-3 py-2 bg-dark-800/90 backdrop-blur border border-dark-600 text-white placeholder-dark-500 rounded-lg text-sm focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleLoadStudy}
|
||||
disabled={!studyId || isLoadingStudy}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoadingStudy ? 'Loading...' : 'Load Study'}
|
||||
</button>
|
||||
{studyPath && (
|
||||
<span className="text-xs text-dark-400 truncate max-w-xs" title={studyPath}>
|
||||
{studyPath.split(/[/\\]/).slice(-2).join('/')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{loadError && (
|
||||
<div className="absolute top-16 left-4 right-4 z-10 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>{loadError}</span>
|
||||
<button onClick={() => setLoadError(null)} className="text-red-400 hover:text-red-200">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges.map(e => ({
|
||||
@@ -203,44 +275,22 @@ function CanvasFlow() {
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="absolute bottom-4 right-4 flex gap-2 z-10">
|
||||
<button
|
||||
onClick={() => setShowChat(!showChat)}
|
||||
className={`px-3 py-2 rounded-lg transition-colors ${
|
||||
showChat
|
||||
? 'bg-primary-600/20 text-primary-400 border border-primary-500/50'
|
||||
: 'bg-dark-800 text-dark-300 hover:bg-dark-700 border border-dark-600'
|
||||
}`}
|
||||
title="Toggle Chat"
|
||||
>
|
||||
{isConnected ? <MessageCircle size={18} /> : <Plug size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleValidate}
|
||||
className="px-4 py-2 bg-dark-700 text-white rounded-lg hover:bg-dark-600 border border-dark-600 transition-colors"
|
||||
>
|
||||
Validate
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={!validation.valid}
|
||||
className={`px-4 py-2 rounded-lg transition-colors border ${
|
||||
validation.valid
|
||||
? 'bg-purple-600 text-white hover:bg-purple-500 border-purple-500'
|
||||
: 'bg-dark-800 text-dark-500 cursor-not-allowed border-dark-700'
|
||||
}`}
|
||||
>
|
||||
Analyze
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExecuteClick}
|
||||
disabled={!validation.valid}
|
||||
disabled={!validation.valid || isExecuting}
|
||||
className={`px-4 py-2 rounded-lg transition-colors border ${
|
||||
validation.valid
|
||||
validation.valid && !isExecuting
|
||||
? 'bg-primary-600 text-white hover:bg-primary-500 border-primary-500'
|
||||
: 'bg-dark-800 text-dark-500 cursor-not-allowed border-dark-700'
|
||||
}`}
|
||||
>
|
||||
Execute with Claude
|
||||
{isExecuting ? 'Creating...' : 'Create Study'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -250,43 +300,8 @@ function CanvasFlow() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Config Panel or Chat */}
|
||||
{showChat ? (
|
||||
<div className="w-96 border-l border-dark-700 flex flex-col bg-dark-850">
|
||||
<div className="p-3 border-b border-dark-700 flex justify-between items-center">
|
||||
<h3 className="font-semibold text-white">Claude Assistant</h3>
|
||||
<button
|
||||
onClick={() => setShowChat(false)}
|
||||
className="text-dark-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{chatError ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
|
||||
<AlertCircle size={32} className="text-red-400 mb-3" />
|
||||
<p className="text-white font-medium mb-1">Connection Error</p>
|
||||
<p className="text-sm text-dark-400 mb-4">{chatError}</p>
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-500 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Reconnect
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<ChatPanel
|
||||
messages={messages}
|
||||
isThinking={isThinking || isExecuting}
|
||||
onSendMessage={sendMessage}
|
||||
isConnected={isConnected}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : selectedNode ? (
|
||||
<NodeConfigPanel nodeId={selectedNode} />
|
||||
) : null}
|
||||
{/* Right: Config Panel */}
|
||||
{selectedNode && <NodeConfigPanel nodeId={selectedNode} />}
|
||||
|
||||
{/* Execute Dialog */}
|
||||
<ExecuteDialog
|
||||
@@ -299,10 +314,20 @@ function CanvasFlow() {
|
||||
);
|
||||
}
|
||||
|
||||
export function AtomizerCanvas() {
|
||||
interface AtomizerCanvasProps {
|
||||
studyId?: string;
|
||||
studyPath?: string;
|
||||
onStudyChange?: (studyId: string) => void;
|
||||
}
|
||||
|
||||
export function AtomizerCanvas({ studyId, studyPath, onStudyChange }: AtomizerCanvasProps = {}) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<CanvasFlow />
|
||||
<CanvasFlow
|
||||
initialStudyId={studyId}
|
||||
initialStudyPath={studyPath}
|
||||
onStudyChange={onStudyChange}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* ModelNodeV2 - Enhanced model node with collapsible file dependencies
|
||||
*
|
||||
* Features:
|
||||
* - Shows main model file (.sim)
|
||||
* - Collapsible section showing related files (.prt, .fem, _i.prt)
|
||||
* - Hover to reveal file path
|
||||
* - Click to introspect model
|
||||
* - Shows solver type badge
|
||||
*/
|
||||
|
||||
import { memo, useState, useCallback, useEffect } from 'react';
|
||||
import { NodeProps, Handle, Position } from 'reactflow';
|
||||
import {
|
||||
Box,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileBox,
|
||||
FileCode,
|
||||
Cpu,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
} from 'lucide-react';
|
||||
import { ModelNodeData } from '../../../lib/canvas/schema';
|
||||
|
||||
interface DependentFile {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'prt' | 'fem' | 'sim' | 'idealized' | 'other';
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
interface IntrospectionResult {
|
||||
expressions: Array<{
|
||||
name: string;
|
||||
value: number | string;
|
||||
units?: string;
|
||||
formula?: string;
|
||||
}>;
|
||||
solver_type?: string;
|
||||
dependent_files?: string[];
|
||||
}
|
||||
|
||||
function ModelNodeV2Component(props: NodeProps<ModelNodeData>) {
|
||||
const { data, selected } = props;
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [dependencies, setDependencies] = useState<DependentFile[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [introspection, setIntrospection] = useState<IntrospectionResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Extract filename from path
|
||||
const fileName = data.filePath ? data.filePath.split(/[/\\]/).pop() : 'No file selected';
|
||||
|
||||
// Load dependencies when expanded
|
||||
const loadDependencies = useCallback(async () => {
|
||||
if (!data.filePath) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Call introspection API to get dependent files
|
||||
const response = await fetch(
|
||||
`/api/nx/introspect`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_path: data.filePath }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to introspect model');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setIntrospection(result);
|
||||
|
||||
// Parse dependent files
|
||||
const deps: DependentFile[] = [];
|
||||
|
||||
if (result.dependent_files) {
|
||||
for (const filePath of result.dependent_files) {
|
||||
const name = filePath.split(/[/\\]/).pop() || filePath;
|
||||
const ext = name.split('.').pop()?.toLowerCase();
|
||||
|
||||
let type: DependentFile['type'] = 'other';
|
||||
if (name.includes('_i.prt')) {
|
||||
type = 'idealized';
|
||||
} else if (ext === 'prt') {
|
||||
type = 'prt';
|
||||
} else if (ext === 'fem' || ext === 'afem') {
|
||||
type = 'fem';
|
||||
} else if (ext === 'sim') {
|
||||
type = 'sim';
|
||||
}
|
||||
|
||||
deps.push({
|
||||
name,
|
||||
path: filePath,
|
||||
type,
|
||||
exists: true, // Assume exists from introspection
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setDependencies(deps);
|
||||
} catch (err) {
|
||||
console.error('Failed to load model dependencies:', err);
|
||||
setError('Failed to introspect');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [data.filePath]);
|
||||
|
||||
// Load on first expand
|
||||
useEffect(() => {
|
||||
if (isExpanded && dependencies.length === 0 && !isLoading && data.filePath) {
|
||||
loadDependencies();
|
||||
}
|
||||
}, [isExpanded, dependencies.length, isLoading, data.filePath, loadDependencies]);
|
||||
|
||||
// Get icon for file type
|
||||
const getFileIcon = (type: DependentFile['type']) => {
|
||||
switch (type) {
|
||||
case 'prt':
|
||||
return <Box size={12} className="text-blue-400" />;
|
||||
case 'fem':
|
||||
return <FileCode size={12} className="text-emerald-400" />;
|
||||
case 'sim':
|
||||
return <Cpu size={12} className="text-violet-400" />;
|
||||
case 'idealized':
|
||||
return <Box size={12} className="text-cyan-400" />;
|
||||
default:
|
||||
return <FileBox size={12} className="text-dark-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative rounded-xl border min-w-[200px] max-w-[280px]
|
||||
bg-dark-800 shadow-xl transition-all duration-150 overflow-hidden
|
||||
${selected ? 'border-primary-400 ring-2 ring-primary-400/30 shadow-primary-500/20' : 'border-dark-600'}
|
||||
${!data.configured ? 'border-dashed border-dark-500' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Input handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!w-3 !h-3 !bg-dark-400 !border-2 !border-dark-600 hover:!bg-primary-400 hover:!border-primary-500 transition-colors"
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="text-blue-400 flex-shrink-0">
|
||||
<Box size={16} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-white text-sm truncate">
|
||||
{data.label || 'Model'}
|
||||
</div>
|
||||
</div>
|
||||
{!data.configured && (
|
||||
<div className="w-2 h-2 rounded-full bg-amber-400 flex-shrink-0 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File info */}
|
||||
<div className="mt-2 text-xs text-dark-300 truncate" title={data.filePath}>
|
||||
{fileName}
|
||||
</div>
|
||||
|
||||
{/* Solver badge */}
|
||||
{introspection?.solver_type && (
|
||||
<div className="mt-2 inline-flex items-center gap-1 px-2 py-0.5 rounded bg-violet-500/20 text-violet-400 text-xs">
|
||||
<Cpu size={10} />
|
||||
{introspection.solver_type}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dependencies section (collapsible) */}
|
||||
{data.filePath && (
|
||||
<div className="border-t border-dark-700">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full px-4 py-2 flex items-center gap-2 text-xs text-dark-400 hover:text-white hover:bg-dark-700/50 transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={12} />
|
||||
) : (
|
||||
<ChevronRight size={12} />
|
||||
)}
|
||||
<span>Dependencies</span>
|
||||
{dependencies.length > 0 && (
|
||||
<span className="ml-auto text-dark-500">{dependencies.length}</span>
|
||||
)}
|
||||
{isLoading && (
|
||||
<RefreshCw size={12} className="ml-auto animate-spin text-primary-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 space-y-1">
|
||||
{error ? (
|
||||
<div className="flex items-center gap-1 text-xs text-red-400">
|
||||
<AlertCircle size={12} />
|
||||
{error}
|
||||
</div>
|
||||
) : dependencies.length === 0 && !isLoading ? (
|
||||
<div className="text-xs text-dark-500 py-1">
|
||||
No dependencies found
|
||||
</div>
|
||||
) : (
|
||||
dependencies.map((dep) => (
|
||||
<div
|
||||
key={dep.path}
|
||||
className="flex items-center gap-2 px-2 py-1 rounded bg-dark-900/50 text-xs"
|
||||
title={dep.path}
|
||||
>
|
||||
{getFileIcon(dep.type)}
|
||||
<span className="flex-1 truncate text-dark-300">{dep.name}</span>
|
||||
{dep.exists ? (
|
||||
<CheckCircle size={10} className="text-emerald-400 flex-shrink-0" />
|
||||
) : (
|
||||
<AlertCircle size={10} className="text-amber-400 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Expressions count */}
|
||||
{introspection?.expressions && introspection.expressions.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-dark-700">
|
||||
<div className="text-xs text-dark-400">
|
||||
{introspection.expressions.length} expressions found
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!w-3 !h-3 !bg-dark-400 !border-2 !border-dark-600 hover:!bg-primary-400 hover:!border-primary-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ModelNodeV2 = memo(ModelNodeV2Component);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ModelNode } from './ModelNode';
|
||||
import { ModelNodeV2 } from './ModelNodeV2';
|
||||
import { SolverNode } from './SolverNode';
|
||||
import { DesignVarNode } from './DesignVarNode';
|
||||
import { ExtractorNode } from './ExtractorNode';
|
||||
@@ -9,6 +10,7 @@ import { SurrogateNode } from './SurrogateNode';
|
||||
|
||||
export {
|
||||
ModelNode,
|
||||
ModelNodeV2,
|
||||
SolverNode,
|
||||
DesignVarNode,
|
||||
ExtractorNode,
|
||||
@@ -18,8 +20,12 @@ export {
|
||||
SurrogateNode,
|
||||
};
|
||||
|
||||
// Use ModelNodeV2 by default for enhanced dependency display
|
||||
// Set USE_LEGACY_MODEL_NODE=true to use the original
|
||||
const useEnhancedModelNode = !import.meta.env.VITE_USE_LEGACY_MODEL_NODE;
|
||||
|
||||
export const nodeTypes = {
|
||||
model: ModelNode,
|
||||
model: useEnhancedModelNode ? ModelNodeV2 : ModelNode,
|
||||
solver: SolverNode,
|
||||
designVar: DesignVarNode,
|
||||
extractor: ExtractorNode,
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
/**
|
||||
* NodePalette - Draggable component library for canvas
|
||||
*
|
||||
* Features:
|
||||
* - Draggable node items for canvas drop
|
||||
* - Collapsible mode (icons only)
|
||||
* - Filterable by node type
|
||||
* - Works with both AtomizerCanvas and SpecRenderer
|
||||
*/
|
||||
|
||||
import { DragEvent } from 'react';
|
||||
import { NodeType } from '../../../lib/canvas/schema';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
Box,
|
||||
Cpu,
|
||||
@@ -9,63 +19,237 @@ import {
|
||||
ShieldAlert,
|
||||
BrainCircuit,
|
||||
Rocket,
|
||||
LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { NodeType } from '../../../lib/canvas/schema';
|
||||
|
||||
interface PaletteItem {
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface PaletteItem {
|
||||
type: NodeType;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
icon: LucideIcon;
|
||||
description: string;
|
||||
color: string;
|
||||
/** Whether this can be added via drag-drop (synthetic nodes cannot) */
|
||||
canAdd: boolean;
|
||||
}
|
||||
|
||||
const PALETTE_ITEMS: PaletteItem[] = [
|
||||
{ type: 'model', label: 'Model', icon: <Box size={18} />, description: 'NX model file (.prt, .sim)', color: 'text-blue-400' },
|
||||
{ type: 'solver', label: 'Solver', icon: <Cpu size={18} />, description: 'Nastran solution type', color: 'text-violet-400' },
|
||||
{ type: 'designVar', label: 'Design Variable', icon: <SlidersHorizontal size={18} />, description: 'Parameter to optimize', color: 'text-emerald-400' },
|
||||
{ type: 'extractor', label: 'Extractor', icon: <FlaskConical size={18} />, description: 'Physics result extraction', color: 'text-cyan-400' },
|
||||
{ type: 'objective', label: 'Objective', icon: <Target size={18} />, description: 'Optimization goal', color: 'text-rose-400' },
|
||||
{ type: 'constraint', label: 'Constraint', icon: <ShieldAlert size={18} />, description: 'Design constraint', color: 'text-amber-400' },
|
||||
{ type: 'algorithm', label: 'Algorithm', icon: <BrainCircuit size={18} />, description: 'Optimization method', color: 'text-indigo-400' },
|
||||
{ type: 'surrogate', label: 'Surrogate', icon: <Rocket size={18} />, description: 'Neural acceleration', color: 'text-pink-400' },
|
||||
export interface NodePaletteProps {
|
||||
/** Whether palette is collapsed (icon-only mode) */
|
||||
collapsed?: boolean;
|
||||
/** Callback when collapse state changes */
|
||||
onToggleCollapse?: () => void;
|
||||
/** Custom className for container */
|
||||
className?: string;
|
||||
/** Filter which node types to show */
|
||||
visibleTypes?: NodeType[];
|
||||
/** Show toggle button */
|
||||
showToggle?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
export const PALETTE_ITEMS: PaletteItem[] = [
|
||||
{
|
||||
type: 'model',
|
||||
label: 'Model',
|
||||
icon: Box,
|
||||
description: 'NX model file (.prt, .sim)',
|
||||
color: 'text-blue-400',
|
||||
canAdd: false, // Synthetic - derived from spec
|
||||
},
|
||||
{
|
||||
type: 'solver',
|
||||
label: 'Solver',
|
||||
icon: Cpu,
|
||||
description: 'Nastran solution type',
|
||||
color: 'text-violet-400',
|
||||
canAdd: false, // Synthetic - derived from model
|
||||
},
|
||||
{
|
||||
type: 'designVar',
|
||||
label: 'Design Variable',
|
||||
icon: SlidersHorizontal,
|
||||
description: 'Parameter to optimize',
|
||||
color: 'text-emerald-400',
|
||||
canAdd: true,
|
||||
},
|
||||
{
|
||||
type: 'extractor',
|
||||
label: 'Extractor',
|
||||
icon: FlaskConical,
|
||||
description: 'Physics result extraction',
|
||||
color: 'text-cyan-400',
|
||||
canAdd: true,
|
||||
},
|
||||
{
|
||||
type: 'objective',
|
||||
label: 'Objective',
|
||||
icon: Target,
|
||||
description: 'Optimization goal',
|
||||
color: 'text-rose-400',
|
||||
canAdd: true,
|
||||
},
|
||||
{
|
||||
type: 'constraint',
|
||||
label: 'Constraint',
|
||||
icon: ShieldAlert,
|
||||
description: 'Design constraint',
|
||||
color: 'text-amber-400',
|
||||
canAdd: true,
|
||||
},
|
||||
{
|
||||
type: 'algorithm',
|
||||
label: 'Algorithm',
|
||||
icon: BrainCircuit,
|
||||
description: 'Optimization method',
|
||||
color: 'text-indigo-400',
|
||||
canAdd: false, // Synthetic - derived from spec.optimization
|
||||
},
|
||||
{
|
||||
type: 'surrogate',
|
||||
label: 'Surrogate',
|
||||
icon: Rocket,
|
||||
description: 'Neural acceleration',
|
||||
color: 'text-pink-400',
|
||||
canAdd: false, // Synthetic - derived from spec.optimization.surrogate
|
||||
},
|
||||
];
|
||||
|
||||
export function NodePalette() {
|
||||
const onDragStart = (event: DragEvent, nodeType: NodeType) => {
|
||||
event.dataTransfer.setData('application/reactflow', nodeType);
|
||||
/** Items that can be added via drag-drop */
|
||||
export const ADDABLE_ITEMS = PALETTE_ITEMS.filter(item => item.canAdd);
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export function NodePalette({
|
||||
collapsed = false,
|
||||
onToggleCollapse,
|
||||
className = '',
|
||||
visibleTypes,
|
||||
showToggle = true,
|
||||
}: NodePaletteProps) {
|
||||
// Filter items if visibleTypes is provided
|
||||
const items = visibleTypes
|
||||
? PALETTE_ITEMS.filter(item => visibleTypes.includes(item.type))
|
||||
: PALETTE_ITEMS;
|
||||
|
||||
const onDragStart = (event: DragEvent, item: PaletteItem) => {
|
||||
if (!item.canAdd) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.setData('application/reactflow', item.type);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-60 bg-dark-850 border-r border-dark-700 flex flex-col">
|
||||
<div className="p-4 border-b border-dark-700">
|
||||
<h3 className="text-sm font-semibold text-dark-300 uppercase tracking-wider">
|
||||
Components
|
||||
</h3>
|
||||
<p className="text-xs text-dark-400 mt-1">
|
||||
Drag to canvas
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{PALETTE_ITEMS.map((item) => (
|
||||
<div
|
||||
key={item.type}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, item.type)}
|
||||
className="flex items-center gap-3 px-3 py-3 bg-dark-800/50 rounded-lg border border-dark-700/50
|
||||
cursor-grab hover:border-primary-500/50 hover:bg-dark-800
|
||||
active:cursor-grabbing transition-all group"
|
||||
// Collapsed mode - icons only
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className={`w-14 bg-dark-850 border-r border-dark-700 flex flex-col ${className}`}>
|
||||
{/* Toggle Button */}
|
||||
{showToggle && onToggleCollapse && (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="p-4 border-b border-dark-700 hover:bg-dark-800 transition-colors flex items-center justify-center"
|
||||
title="Expand palette"
|
||||
>
|
||||
<div className={`${item.color} opacity-90 group-hover:opacity-100 transition-opacity`}>
|
||||
{item.icon}
|
||||
<ChevronRight size={18} className="text-dark-400" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Collapsed Items */}
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isDraggable = item.canAdd;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.type}
|
||||
draggable={isDraggable}
|
||||
onDragStart={(e) => onDragStart(e, item)}
|
||||
className={`p-3 mx-2 my-1 rounded-lg transition-all flex items-center justify-center
|
||||
${isDraggable
|
||||
? 'cursor-grab hover:bg-dark-800 active:cursor-grabbing'
|
||||
: 'cursor-default opacity-50'
|
||||
}`}
|
||||
title={`${item.label}${!isDraggable ? ' (auto-created)' : ''}`}
|
||||
>
|
||||
<Icon size={18} className={item.color} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded mode - full display
|
||||
return (
|
||||
<div className={`w-60 bg-dark-850 border-r border-dark-700 flex flex-col ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-dark-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-dark-300 uppercase tracking-wider">
|
||||
Components
|
||||
</h3>
|
||||
<p className="text-xs text-dark-400 mt-1">
|
||||
Drag to canvas
|
||||
</p>
|
||||
</div>
|
||||
{showToggle && onToggleCollapse && (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="p-1.5 rounded hover:bg-dark-800 transition-colors"
|
||||
title="Collapse palette"
|
||||
>
|
||||
<ChevronLeft size={16} className="text-dark-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isDraggable = item.canAdd;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.type}
|
||||
draggable={isDraggable}
|
||||
onDragStart={(e) => onDragStart(e, item)}
|
||||
className={`flex items-center gap-3 px-3 py-3 rounded-lg border transition-all group
|
||||
${isDraggable
|
||||
? 'bg-dark-800/50 border-dark-700/50 cursor-grab hover:border-primary-500/50 hover:bg-dark-800 active:cursor-grabbing'
|
||||
: 'bg-dark-900/30 border-dark-800/30 cursor-default'
|
||||
}`}
|
||||
title={!isDraggable ? 'Auto-created from study configuration' : undefined}
|
||||
>
|
||||
<div className={`${item.color} ${isDraggable ? 'opacity-90 group-hover:opacity-100' : 'opacity-50'} transition-opacity`}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`font-semibold text-sm leading-tight ${isDraggable ? 'text-white' : 'text-dark-400'}`}>
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="text-xs text-dark-400 truncate">
|
||||
{isDraggable ? item.description : 'Auto-created'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-white text-sm leading-tight">{item.label}</div>
|
||||
<div className="text-xs text-dark-400 truncate">{item.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NodePalette;
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* FileStructurePanel - Shows study file structure in the canvas sidebar
|
||||
*
|
||||
* Features:
|
||||
* - Tree view of study directory
|
||||
* - Highlights model files (.prt, .fem, .sim)
|
||||
* - Shows file dependencies
|
||||
* - One-click to set as model source
|
||||
* - Refresh button to reload
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FileBox,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Box,
|
||||
Cpu,
|
||||
FileCode,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface FileNode {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
extension?: string;
|
||||
size?: number;
|
||||
children?: FileNode[];
|
||||
isModelFile?: boolean;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
interface FileStructurePanelProps {
|
||||
studyId: string | null;
|
||||
onModelSelect?: (filePath: string, fileType: string) => void;
|
||||
selectedModelPath?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// File type to icon mapping
|
||||
const FILE_ICONS: Record<string, { icon: typeof FileBox; color: string }> = {
|
||||
'.prt': { icon: Box, color: 'text-blue-400' },
|
||||
'.sim': { icon: Cpu, color: 'text-violet-400' },
|
||||
'.fem': { icon: FileCode, color: 'text-emerald-400' },
|
||||
'.afem': { icon: FileCode, color: 'text-emerald-400' },
|
||||
'.dat': { icon: FileBox, color: 'text-amber-400' },
|
||||
'.bdf': { icon: FileBox, color: 'text-amber-400' },
|
||||
'.op2': { icon: FileBox, color: 'text-rose-400' },
|
||||
'.f06': { icon: FileBox, color: 'text-dark-400' },
|
||||
};
|
||||
|
||||
const MODEL_EXTENSIONS = ['.prt', '.sim', '.fem', '.afem'];
|
||||
|
||||
export function FileStructurePanel({
|
||||
studyId,
|
||||
onModelSelect,
|
||||
selectedModelPath,
|
||||
className = '',
|
||||
}: FileStructurePanelProps) {
|
||||
const [files, setFiles] = useState<FileNode[]>([]);
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load study file structure
|
||||
const loadFileStructure = useCallback(async () => {
|
||||
if (!studyId) {
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/files/structure/${encodeURIComponent(studyId)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setError('Study not found');
|
||||
} else {
|
||||
throw new Error(`Failed to load: ${response.status}`);
|
||||
}
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Process the file tree to mark model files
|
||||
const processNode = (node: FileNode): FileNode => {
|
||||
if (node.type === 'directory' && node.children) {
|
||||
return {
|
||||
...node,
|
||||
children: node.children.map(processNode),
|
||||
};
|
||||
}
|
||||
|
||||
const ext = '.' + node.name.split('.').pop()?.toLowerCase();
|
||||
return {
|
||||
...node,
|
||||
extension: ext,
|
||||
isModelFile: MODEL_EXTENSIONS.includes(ext),
|
||||
isSelected: node.path === selectedModelPath,
|
||||
};
|
||||
};
|
||||
|
||||
const processedFiles = (data.files || []).map(processNode);
|
||||
setFiles(processedFiles);
|
||||
|
||||
// Auto-expand 1_setup and root directories
|
||||
const toExpand = new Set<string>();
|
||||
processedFiles.forEach((node: FileNode) => {
|
||||
if (node.type === 'directory') {
|
||||
toExpand.add(node.path);
|
||||
if (node.name === '1_setup' && node.children) {
|
||||
node.children.forEach((child: FileNode) => {
|
||||
if (child.type === 'directory') {
|
||||
toExpand.add(child.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
setExpandedPaths(toExpand);
|
||||
} catch (err) {
|
||||
console.error('Failed to load file structure:', err);
|
||||
setError('Failed to load files');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [studyId, selectedModelPath]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFileStructure();
|
||||
}, [loadFileStructure]);
|
||||
|
||||
// Toggle directory expansion
|
||||
const toggleExpand = (path: string) => {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle file selection
|
||||
const handleFileClick = (node: FileNode) => {
|
||||
if (node.type === 'directory') {
|
||||
toggleExpand(node.path);
|
||||
} else if (node.isModelFile && onModelSelect) {
|
||||
onModelSelect(node.path, node.extension || '');
|
||||
}
|
||||
};
|
||||
|
||||
// Render a file/folder node
|
||||
const renderNode = (node: FileNode, depth: number = 0) => {
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
const isDirectory = node.type === 'directory';
|
||||
const fileInfo = node.extension ? FILE_ICONS[node.extension] : null;
|
||||
const Icon = isDirectory
|
||||
? isExpanded
|
||||
? FolderOpen
|
||||
: Folder
|
||||
: fileInfo?.icon || FileBox;
|
||||
const iconColor = isDirectory
|
||||
? 'text-amber-400'
|
||||
: fileInfo?.color || 'text-dark-400';
|
||||
|
||||
const isSelected = node.path === selectedModelPath;
|
||||
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<button
|
||||
onClick={() => handleFileClick(node)}
|
||||
className={`
|
||||
w-full flex items-center gap-2 px-2 py-1.5 text-left text-sm rounded-md
|
||||
transition-colors group
|
||||
${isSelected ? 'bg-primary-500/20 text-primary-400' : 'hover:bg-dark-700/50'}
|
||||
${node.isModelFile ? 'cursor-pointer' : isDirectory ? 'cursor-pointer' : 'cursor-default opacity-60'}
|
||||
`}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
>
|
||||
{/* Expand/collapse chevron for directories */}
|
||||
{isDirectory ? (
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`text-dark-500 transition-transform flex-shrink-0 ${
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
) : (
|
||||
<span className="w-3.5 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<Icon size={16} className={`${iconColor} flex-shrink-0`} />
|
||||
|
||||
{/* Name */}
|
||||
<span
|
||||
className={`flex-1 truncate ${
|
||||
isSelected ? 'text-primary-400 font-medium' : 'text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{node.name}
|
||||
</span>
|
||||
|
||||
{/* Model file indicator */}
|
||||
{node.isModelFile && !isSelected && (
|
||||
<span title="Set as model">
|
||||
<Plus
|
||||
size={14}
|
||||
className="text-dark-500 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Selected indicator */}
|
||||
{isSelected && (
|
||||
<CheckCircle size={14} className="text-primary-400 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Children */}
|
||||
{isDirectory && isExpanded && node.children && (
|
||||
<div>
|
||||
{node.children.map((child) => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// No study selected state
|
||||
if (!studyId) {
|
||||
return (
|
||||
<div className={`p-4 ${className}`}>
|
||||
<div className="text-center text-dark-400 text-sm">
|
||||
<Folder size={32} className="mx-auto mb-2 text-dark-500" />
|
||||
<p>No study selected</p>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
Load a study to see its files
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder size={16} className="text-amber-400" />
|
||||
<span className="text-sm font-medium text-white">Files</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadFileStructure}
|
||||
disabled={isLoading}
|
||||
className="p-1 rounded hover:bg-dark-700 text-dark-400 hover:text-white transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{isLoading && files.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-20 text-dark-400 text-sm">
|
||||
<RefreshCw size={16} className="animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-20 text-red-400 text-sm gap-2">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="text-center text-dark-400 text-sm py-4">
|
||||
<p>No files found</p>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
Add model files to 1_setup/
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{files.map((node) => renderNode(node))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="px-3 py-2 border-t border-dark-700 text-xs text-dark-500">
|
||||
Click a model file to select it
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileStructurePanel;
|
||||
@@ -0,0 +1,684 @@
|
||||
/**
|
||||
* NodeConfigPanelV2 - Configuration panel for AtomizerSpec v2.0 nodes
|
||||
*
|
||||
* This component uses useSpecStore instead of the legacy useCanvasStore.
|
||||
* It renders type-specific configuration forms based on the selected node.
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Microscope, Trash2, X, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
useSpecStore,
|
||||
useSpec,
|
||||
useSelectedNodeId,
|
||||
useSelectedNode,
|
||||
} from '../../../hooks/useSpecStore';
|
||||
import { FileBrowser } from './FileBrowser';
|
||||
import { IntrospectionPanel } from './IntrospectionPanel';
|
||||
import {
|
||||
DesignVariable,
|
||||
Extractor,
|
||||
Objective,
|
||||
Constraint,
|
||||
} from '../../../types/atomizer-spec';
|
||||
|
||||
// Common input class for dark theme
|
||||
const inputClass = "w-full px-3 py-2 bg-dark-800 border border-dark-600 text-white placeholder-dark-400 rounded-lg focus:border-primary-500 focus:outline-none transition-colors";
|
||||
const selectClass = "w-full px-3 py-2 bg-dark-800 border border-dark-600 text-white rounded-lg focus:border-primary-500 focus:outline-none transition-colors";
|
||||
const labelClass = "block text-sm font-medium text-dark-300 mb-1";
|
||||
|
||||
interface NodeConfigPanelV2Props {
|
||||
/** Called when panel should close */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) {
|
||||
const spec = useSpec();
|
||||
const selectedNodeId = useSelectedNodeId();
|
||||
const selectedNode = useSelectedNode();
|
||||
const { updateNode, removeNode, clearSelection } = useSpecStore();
|
||||
|
||||
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
||||
const [showIntrospection, setShowIntrospection] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Determine node type from ID prefix or from the node itself
|
||||
const nodeType = useMemo(() => {
|
||||
if (!selectedNodeId) return null;
|
||||
|
||||
// Synthetic nodes have fixed IDs
|
||||
if (selectedNodeId === 'model') return 'model';
|
||||
if (selectedNodeId === 'solver') return 'solver';
|
||||
if (selectedNodeId === 'algorithm') return 'algorithm';
|
||||
if (selectedNodeId === 'surrogate') return 'surrogate';
|
||||
|
||||
// Real nodes have prefixed IDs
|
||||
const prefix = selectedNodeId.split('_')[0];
|
||||
switch (prefix) {
|
||||
case 'dv': return 'designVar';
|
||||
case 'ext': return 'extractor';
|
||||
case 'obj': return 'objective';
|
||||
case 'con': return 'constraint';
|
||||
default: return null;
|
||||
}
|
||||
}, [selectedNodeId]);
|
||||
|
||||
// Get label for display
|
||||
const nodeLabel = useMemo(() => {
|
||||
if (!selectedNodeId || !spec) return 'Node';
|
||||
|
||||
switch (nodeType) {
|
||||
case 'model': return spec.meta.study_name || 'Model';
|
||||
case 'solver': return spec.model.sim?.solution_type || 'Solver';
|
||||
case 'algorithm': return spec.optimization.algorithm?.type || 'Algorithm';
|
||||
case 'surrogate': return 'Neural Surrogate';
|
||||
default:
|
||||
if (selectedNode) {
|
||||
return (selectedNode as any).name || selectedNodeId;
|
||||
}
|
||||
return selectedNodeId;
|
||||
}
|
||||
}, [selectedNodeId, selectedNode, nodeType, spec]);
|
||||
|
||||
// Handle field changes
|
||||
const handleChange = useCallback(async (field: string, value: unknown) => {
|
||||
if (!selectedNodeId || !selectedNode) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await updateNode(selectedNodeId, { [field]: value });
|
||||
} catch (err) {
|
||||
console.error('Failed to update node:', err);
|
||||
setError(err instanceof Error ? err.message : 'Update failed');
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [selectedNodeId, selectedNode, updateNode]);
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!selectedNodeId) return;
|
||||
|
||||
// Synthetic nodes can't be deleted
|
||||
if (['model', 'solver', 'algorithm', 'surrogate'].includes(selectedNodeId)) {
|
||||
setError('This node cannot be deleted');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await removeNode(selectedNodeId);
|
||||
clearSelection();
|
||||
onClose?.();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete node:', err);
|
||||
setError(err instanceof Error ? err.message : 'Delete failed');
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [selectedNodeId, removeNode, clearSelection, onClose]);
|
||||
|
||||
// Don't render if no node selected
|
||||
if (!selectedNodeId || !spec) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if this is a synthetic node (model, solver, algorithm, surrogate)
|
||||
const isSyntheticNode = ['model', 'solver', 'algorithm', 'surrogate'].includes(selectedNodeId);
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-dark-850 border-l border-dark-700 flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-4 border-b border-dark-700">
|
||||
<h3 className="font-semibold text-white truncate flex-1">
|
||||
Configure {nodeLabel}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isSyntheticNode && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isUpdating}
|
||||
className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded transition-colors"
|
||||
title="Delete node"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
title="Close panel"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="mx-4 mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg flex items-start gap-2">
|
||||
<AlertCircle size={16} className="text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-400 flex-1">{error}</p>
|
||||
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
{/* Loading indicator */}
|
||||
{isUpdating && (
|
||||
<div className="text-xs text-primary-400 animate-pulse">Updating...</div>
|
||||
)}
|
||||
|
||||
{/* Model node (synthetic) */}
|
||||
{nodeType === 'model' && spec.model && (
|
||||
<ModelNodeConfig spec={spec} />
|
||||
)}
|
||||
|
||||
{/* Solver node (synthetic) */}
|
||||
{nodeType === 'solver' && (
|
||||
<SolverNodeConfig spec={spec} />
|
||||
)}
|
||||
|
||||
{/* Algorithm node (synthetic) */}
|
||||
{nodeType === 'algorithm' && (
|
||||
<AlgorithmNodeConfig spec={spec} />
|
||||
)}
|
||||
|
||||
{/* Surrogate node (synthetic) */}
|
||||
{nodeType === 'surrogate' && (
|
||||
<SurrogateNodeConfig spec={spec} />
|
||||
)}
|
||||
|
||||
{/* Design Variable */}
|
||||
{nodeType === 'designVar' && selectedNode && (
|
||||
<DesignVarNodeConfig
|
||||
node={selectedNode as DesignVariable}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Extractor */}
|
||||
{nodeType === 'extractor' && selectedNode && (
|
||||
<ExtractorNodeConfig
|
||||
node={selectedNode as Extractor}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Objective */}
|
||||
{nodeType === 'objective' && selectedNode && (
|
||||
<ObjectiveNodeConfig
|
||||
node={selectedNode as Objective}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Constraint */}
|
||||
{nodeType === 'constraint' && selectedNode && (
|
||||
<ConstraintNodeConfig
|
||||
node={selectedNode as Constraint}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Browser Modal */}
|
||||
<FileBrowser
|
||||
isOpen={showFileBrowser}
|
||||
onClose={() => setShowFileBrowser(false)}
|
||||
onSelect={() => {
|
||||
// This would update the model path - but model is synthetic
|
||||
setShowFileBrowser(false);
|
||||
}}
|
||||
fileTypes={['.sim', '.prt', '.fem', '.afem']}
|
||||
/>
|
||||
|
||||
{/* Introspection Panel */}
|
||||
{showIntrospection && spec.model.sim?.path && (
|
||||
<div className="fixed top-20 right-96 z-40">
|
||||
<IntrospectionPanel
|
||||
filePath={spec.model.sim.path}
|
||||
onClose={() => setShowIntrospection(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Type-specific configuration components
|
||||
// ============================================================================
|
||||
|
||||
interface SpecConfigProps {
|
||||
spec: NonNullable<ReturnType<typeof useSpec>>;
|
||||
}
|
||||
|
||||
function ModelNodeConfig({ spec }: SpecConfigProps) {
|
||||
const [showIntrospection, setShowIntrospection] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelClass}>Model File</label>
|
||||
<input
|
||||
type="text"
|
||||
value={spec.model.sim?.path || ''}
|
||||
readOnly
|
||||
className={`${inputClass} font-mono text-sm bg-dark-900 cursor-not-allowed`}
|
||||
title="Model path is read-only. Change via study configuration."
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">Read-only. Set in study configuration.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Solver Type</label>
|
||||
<input
|
||||
type="text"
|
||||
value={spec.model.sim?.solution_type || 'Not detected'}
|
||||
readOnly
|
||||
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{spec.model.sim?.path && (
|
||||
<button
|
||||
onClick={() => setShowIntrospection(true)}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 bg-primary-500/20
|
||||
hover:bg-primary-500/30 border border-primary-500/30 rounded-lg
|
||||
text-primary-400 text-sm font-medium transition-colors"
|
||||
>
|
||||
<Microscope size={16} />
|
||||
Introspect Model
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showIntrospection && spec.model.sim?.path && (
|
||||
<div className="fixed top-20 right-96 z-40">
|
||||
<IntrospectionPanel
|
||||
filePath={spec.model.sim.path}
|
||||
onClose={() => setShowIntrospection(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SolverNodeConfig({ spec }: SpecConfigProps) {
|
||||
return (
|
||||
<div>
|
||||
<label className={labelClass}>Solution Type</label>
|
||||
<input
|
||||
type="text"
|
||||
value={spec.model.sim?.solution_type || 'Not configured'}
|
||||
readOnly
|
||||
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
|
||||
title="Solver type is determined by the model file."
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">Detected from model file.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlgorithmNodeConfig({ spec }: SpecConfigProps) {
|
||||
const algo = spec.optimization.algorithm;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelClass}>Method</label>
|
||||
<input
|
||||
type="text"
|
||||
value={algo?.type || 'TPE'}
|
||||
readOnly
|
||||
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">Edit in optimization settings.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Max Trials</label>
|
||||
<input
|
||||
type="number"
|
||||
value={spec.optimization.budget?.max_trials || 100}
|
||||
readOnly
|
||||
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SurrogateNodeConfig({ spec }: SpecConfigProps) {
|
||||
const surrogate = spec.optimization.surrogate;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="surrogate-enabled"
|
||||
checked={surrogate?.enabled || false}
|
||||
readOnly
|
||||
className="w-4 h-4 rounded bg-dark-800 border-dark-600 text-primary-500 cursor-not-allowed"
|
||||
/>
|
||||
<label htmlFor="surrogate-enabled" className="text-sm font-medium text-dark-300">
|
||||
Neural Surrogate {surrogate?.enabled ? 'Enabled' : 'Disabled'}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{surrogate?.enabled && (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelClass}>Model Type</label>
|
||||
<input
|
||||
type="text"
|
||||
value={surrogate.type || 'MLP'}
|
||||
readOnly
|
||||
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Min Training Samples</label>
|
||||
<input
|
||||
type="number"
|
||||
value={surrogate.config?.min_training_samples || 20}
|
||||
readOnly
|
||||
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-dark-500">Edit in optimization settings.</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Editable node configs
|
||||
// ============================================================================
|
||||
|
||||
interface DesignVarNodeConfigProps {
|
||||
node: DesignVariable;
|
||||
onChange: (field: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
function DesignVarNodeConfig({ node, onChange }: DesignVarNodeConfigProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelClass}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={node.name}
|
||||
onChange={(e) => onChange('name', e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Expression Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={node.expression_name}
|
||||
onChange={(e) => onChange('expression_name', e.target.value)}
|
||||
placeholder="NX expression name"
|
||||
className={`${inputClass} font-mono text-sm`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className={labelClass}>Min</label>
|
||||
<input
|
||||
type="number"
|
||||
value={node.bounds.min}
|
||||
onChange={(e) => onChange('bounds', { ...node.bounds, min: parseFloat(e.target.value) })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Max</label>
|
||||
<input
|
||||
type="number"
|
||||
value={node.bounds.max}
|
||||
onChange={(e) => onChange('bounds', { ...node.bounds, max: parseFloat(e.target.value) })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{node.baseline !== undefined && (
|
||||
<div>
|
||||
<label className={labelClass}>Baseline</label>
|
||||
<input
|
||||
type="number"
|
||||
value={node.baseline}
|
||||
onChange={(e) => onChange('baseline', parseFloat(e.target.value))}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Units</label>
|
||||
<input
|
||||
type="text"
|
||||
value={node.units || ''}
|
||||
onChange={(e) => onChange('units', e.target.value)}
|
||||
placeholder="mm"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`${node.id}-enabled`}
|
||||
checked={node.enabled !== false}
|
||||
onChange={(e) => onChange('enabled', e.target.checked)}
|
||||
className="w-4 h-4 rounded bg-dark-800 border-dark-600 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<label htmlFor={`${node.id}-enabled`} className="text-sm text-dark-300">
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExtractorNodeConfigProps {
|
||||
node: Extractor;
|
||||
onChange: (field: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) {
|
||||
const extractorOptions = [
|
||||
{ id: 'E1', name: 'Displacement', type: 'displacement' },
|
||||
{ id: 'E2', name: 'Frequency', type: 'frequency' },
|
||||
{ id: 'E3', name: 'Solid Stress', type: 'solid_stress' },
|
||||
{ id: 'E4', name: 'BDF Mass', type: 'mass_bdf' },
|
||||
{ id: 'E5', name: 'CAD Mass', type: 'mass_expression' },
|
||||
{ id: 'E8', name: 'Zernike (OP2)', type: 'zernike_op2' },
|
||||
{ id: 'E9', name: 'Zernike (CSV)', type: 'zernike_csv' },
|
||||
{ id: 'E10', name: 'Zernike (RMS)', type: 'zernike_rms' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelClass}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={node.name}
|
||||
onChange={(e) => onChange('name', e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Extractor Type</label>
|
||||
<select
|
||||
value={node.type}
|
||||
onChange={(e) => onChange('type', e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{extractorOptions.map(opt => (
|
||||
<option key={opt.id} value={opt.type}>
|
||||
{opt.id} - {opt.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">Custom Function</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{node.type === 'custom_function' && node.function && (
|
||||
<div>
|
||||
<label className={labelClass}>Custom Function</label>
|
||||
<input
|
||||
type="text"
|
||||
value={node.function.name || ''}
|
||||
readOnly
|
||||
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">Edit custom code in dedicated editor.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Outputs</label>
|
||||
<input
|
||||
type="text"
|
||||
value={node.outputs?.map(o => o.name).join(', ') || ''}
|
||||
readOnly
|
||||
placeholder="value, unit"
|
||||
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">Outputs are defined by extractor type.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ObjectiveNodeConfigProps {
|
||||
node: Objective;
|
||||
onChange: (field: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
function ObjectiveNodeConfig({ node, onChange }: ObjectiveNodeConfigProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelClass}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={node.name}
|
||||
onChange={(e) => onChange('name', e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Direction</label>
|
||||
<select
|
||||
value={node.direction}
|
||||
onChange={(e) => onChange('direction', e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="minimize">Minimize</option>
|
||||
<option value="maximize">Maximize</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Weight</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={node.weight ?? 1}
|
||||
onChange={(e) => onChange('weight', parseFloat(e.target.value))}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{node.target !== undefined && (
|
||||
<div>
|
||||
<label className={labelClass}>Target Value</label>
|
||||
<input
|
||||
type="number"
|
||||
value={node.target}
|
||||
onChange={(e) => onChange('target', parseFloat(e.target.value))}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConstraintNodeConfigProps {
|
||||
node: Constraint;
|
||||
onChange: (field: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
function ConstraintNodeConfig({ node, onChange }: ConstraintNodeConfigProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelClass}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={node.name}
|
||||
onChange={(e) => onChange('name', e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className={labelClass}>Type</label>
|
||||
<select
|
||||
value={node.type}
|
||||
onChange={(e) => onChange('type', e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="less_than">< Less than</option>
|
||||
<option value="less_equal"><= Less or equal</option>
|
||||
<option value="greater_than">> Greater than</option>
|
||||
<option value="greater_equal">>= Greater or equal</option>
|
||||
<option value="equal">= Equal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Threshold</label>
|
||||
<input
|
||||
type="number"
|
||||
value={node.threshold}
|
||||
onChange={(e) => onChange('threshold', parseFloat(e.target.value))}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default NodeConfigPanelV2;
|
||||
Reference in New Issue
Block a user