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,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;
|
||||
|
||||
Reference in New Issue
Block a user