feat: Phase 1 - Canvas with React Flow
- 8 node types (Model, Solver, DesignVar, Extractor, Objective, Constraint, Algorithm, Surrogate) - Drag-drop from palette to canvas - Node configuration panels - Graph validation with error/warning display - Intent JSON serialization - Zustand state management Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
173
atomizer-dashboard/frontend/src/lib/canvas/intent.ts
Normal file
173
atomizer-dashboard/frontend/src/lib/canvas/intent.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Intent Serializer - Convert canvas graph to Intent JSON for Claude
|
||||
*/
|
||||
|
||||
import { Node, Edge } from 'reactflow';
|
||||
import { CanvasNodeData, ExtractorNodeData } from './schema';
|
||||
|
||||
export interface OptimizationIntent {
|
||||
version: '1.0';
|
||||
source: 'canvas';
|
||||
timestamp: string;
|
||||
model: {
|
||||
path?: string;
|
||||
type?: string;
|
||||
};
|
||||
solver: {
|
||||
type?: string;
|
||||
};
|
||||
design_variables: Array<{
|
||||
name: string;
|
||||
min: number;
|
||||
max: number;
|
||||
unit?: string;
|
||||
}>;
|
||||
extractors: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
config?: Record<string, unknown>;
|
||||
}>;
|
||||
objectives: Array<{
|
||||
name: string;
|
||||
direction: 'minimize' | 'maximize';
|
||||
weight: number;
|
||||
extractor: string;
|
||||
}>;
|
||||
constraints: Array<{
|
||||
name: string;
|
||||
operator: string;
|
||||
value: number;
|
||||
extractor: string;
|
||||
}>;
|
||||
optimization: {
|
||||
method?: string;
|
||||
max_trials?: number;
|
||||
};
|
||||
surrogate?: {
|
||||
enabled: boolean;
|
||||
type?: string;
|
||||
min_trials?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeToIntent(
|
||||
nodes: Node<CanvasNodeData>[],
|
||||
edges: Edge[]
|
||||
): OptimizationIntent {
|
||||
const intent: OptimizationIntent = {
|
||||
version: '1.0',
|
||||
source: 'canvas',
|
||||
timestamp: new Date().toISOString(),
|
||||
model: {},
|
||||
solver: {},
|
||||
design_variables: [],
|
||||
extractors: [],
|
||||
objectives: [],
|
||||
constraints: [],
|
||||
optimization: {},
|
||||
};
|
||||
|
||||
// Helper to find connected nodes
|
||||
const getConnectedNodes = (nodeId: string, direction: 'source' | 'target') => {
|
||||
return edges
|
||||
.filter(e => direction === 'source' ? e.source === nodeId : e.target === nodeId)
|
||||
.map(e => direction === 'source' ? e.target : e.source)
|
||||
.map(id => nodes.find(n => n.id === id))
|
||||
.filter(Boolean) as Node<CanvasNodeData>[];
|
||||
};
|
||||
|
||||
// Process each node type
|
||||
for (const node of nodes) {
|
||||
const data = node.data;
|
||||
|
||||
switch (data.type) {
|
||||
case 'model':
|
||||
intent.model = {
|
||||
path: data.filePath,
|
||||
type: data.fileType,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'solver':
|
||||
intent.solver = {
|
||||
type: data.solverType,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'designVar':
|
||||
if (data.expressionName) {
|
||||
intent.design_variables.push({
|
||||
name: data.expressionName,
|
||||
min: data.minValue ?? 0,
|
||||
max: data.maxValue ?? 100,
|
||||
unit: data.unit,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'extractor':
|
||||
if (data.extractorId) {
|
||||
intent.extractors.push({
|
||||
id: data.extractorId,
|
||||
name: data.extractorName ?? data.extractorId,
|
||||
config: data.config,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'objective':
|
||||
if (data.name) {
|
||||
// Find connected extractor
|
||||
const sourceNodes = getConnectedNodes(node.id, 'target');
|
||||
const extractor = sourceNodes.find(n => n.data.type === 'extractor');
|
||||
|
||||
intent.objectives.push({
|
||||
name: data.name,
|
||||
direction: data.direction ?? 'minimize',
|
||||
weight: data.weight ?? 1,
|
||||
extractor: extractor?.data.type === 'extractor'
|
||||
? (extractor.data as ExtractorNodeData).extractorId ?? ''
|
||||
: '',
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'constraint':
|
||||
if (data.name) {
|
||||
const sourceNodes = getConnectedNodes(node.id, 'target');
|
||||
const extractor = sourceNodes.find(n => n.data.type === 'extractor');
|
||||
|
||||
intent.constraints.push({
|
||||
name: data.name,
|
||||
operator: data.operator ?? '<=',
|
||||
value: data.value ?? 0,
|
||||
extractor: extractor?.data.type === 'extractor'
|
||||
? (extractor.data as ExtractorNodeData).extractorId ?? ''
|
||||
: '',
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'algorithm':
|
||||
intent.optimization = {
|
||||
method: data.method,
|
||||
max_trials: data.maxTrials,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'surrogate':
|
||||
intent.surrogate = {
|
||||
enabled: data.enabled ?? false,
|
||||
type: data.modelType,
|
||||
min_trials: data.minTrials,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return intent;
|
||||
}
|
||||
|
||||
export function formatIntentForChat(intent: OptimizationIntent): string {
|
||||
return `INTENT:${JSON.stringify(intent)}`;
|
||||
}
|
||||
102
atomizer-dashboard/frontend/src/lib/canvas/schema.ts
Normal file
102
atomizer-dashboard/frontend/src/lib/canvas/schema.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Canvas Schema - Type definitions for optimization workflow nodes
|
||||
*/
|
||||
|
||||
export type NodeType =
|
||||
| 'model'
|
||||
| 'solver'
|
||||
| 'designVar'
|
||||
| 'extractor'
|
||||
| 'objective'
|
||||
| 'constraint'
|
||||
| 'algorithm'
|
||||
| 'surrogate';
|
||||
|
||||
export interface BaseNodeData {
|
||||
label: string;
|
||||
configured: boolean;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
export interface ModelNodeData extends BaseNodeData {
|
||||
type: 'model';
|
||||
filePath?: string;
|
||||
fileType?: 'prt' | 'fem' | 'sim';
|
||||
}
|
||||
|
||||
export interface SolverNodeData extends BaseNodeData {
|
||||
type: 'solver';
|
||||
solverType?: 'SOL101' | 'SOL103' | 'SOL105' | 'SOL106' | 'SOL111' | 'SOL112';
|
||||
}
|
||||
|
||||
export interface DesignVarNodeData extends BaseNodeData {
|
||||
type: 'designVar';
|
||||
expressionName?: string;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface ExtractorNodeData extends BaseNodeData {
|
||||
type: 'extractor';
|
||||
extractorId?: string;
|
||||
extractorName?: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ObjectiveNodeData extends BaseNodeData {
|
||||
type: 'objective';
|
||||
name?: string;
|
||||
direction?: 'minimize' | 'maximize';
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
export interface ConstraintNodeData extends BaseNodeData {
|
||||
type: 'constraint';
|
||||
name?: string;
|
||||
operator?: '<' | '<=' | '>' | '>=' | '==';
|
||||
value?: number;
|
||||
}
|
||||
|
||||
export interface AlgorithmNodeData extends BaseNodeData {
|
||||
type: 'algorithm';
|
||||
method?: 'TPE' | 'CMA-ES' | 'NSGA-II' | 'GP-BO' | 'RandomSearch';
|
||||
maxTrials?: number;
|
||||
}
|
||||
|
||||
export interface SurrogateNodeData extends BaseNodeData {
|
||||
type: 'surrogate';
|
||||
enabled?: boolean;
|
||||
modelType?: 'MLP' | 'GNN' | 'Ensemble';
|
||||
minTrials?: number;
|
||||
}
|
||||
|
||||
export type CanvasNodeData =
|
||||
| ModelNodeData
|
||||
| SolverNodeData
|
||||
| DesignVarNodeData
|
||||
| ExtractorNodeData
|
||||
| ObjectiveNodeData
|
||||
| ConstraintNodeData
|
||||
| AlgorithmNodeData
|
||||
| SurrogateNodeData;
|
||||
|
||||
export interface CanvasEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
}
|
||||
|
||||
// Valid connections
|
||||
export const VALID_CONNECTIONS: Record<NodeType, NodeType[]> = {
|
||||
model: ['solver', 'designVar'],
|
||||
solver: ['extractor'],
|
||||
designVar: ['model'],
|
||||
extractor: ['objective', 'constraint'],
|
||||
objective: ['algorithm'],
|
||||
constraint: ['algorithm'],
|
||||
algorithm: ['surrogate'],
|
||||
surrogate: [],
|
||||
};
|
||||
91
atomizer-dashboard/frontend/src/lib/canvas/validation.ts
Normal file
91
atomizer-dashboard/frontend/src/lib/canvas/validation.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Canvas Validation - Validate optimization workflow graphs
|
||||
*/
|
||||
|
||||
import { Node, Edge } from 'reactflow';
|
||||
import { CanvasNodeData, NodeType, VALID_CONNECTIONS } from './schema';
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export function validateGraph(
|
||||
nodes: Node<CanvasNodeData>[],
|
||||
edges: Edge[]
|
||||
): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check required nodes exist
|
||||
const nodeTypes = new Set(nodes.map(n => n.data.type));
|
||||
|
||||
if (!nodeTypes.has('model')) {
|
||||
errors.push('Missing Model node - add an NX model file');
|
||||
}
|
||||
if (!nodeTypes.has('solver')) {
|
||||
errors.push('Missing Solver node - specify solution type');
|
||||
}
|
||||
if (!nodeTypes.has('objective')) {
|
||||
errors.push('Missing Objective node - define what to optimize');
|
||||
}
|
||||
if (!nodeTypes.has('algorithm')) {
|
||||
errors.push('Missing Algorithm node - select optimization method');
|
||||
}
|
||||
|
||||
// Check design variables
|
||||
const designVars = nodes.filter(n => n.data.type === 'designVar');
|
||||
if (designVars.length === 0) {
|
||||
errors.push('No design variables - add at least one parameter to vary');
|
||||
}
|
||||
|
||||
// Check extractors
|
||||
const extractors = nodes.filter(n => n.data.type === 'extractor');
|
||||
if (extractors.length === 0) {
|
||||
errors.push('No extractors - add physics extractors for objectives');
|
||||
}
|
||||
|
||||
// Check node configurations
|
||||
for (const node of nodes) {
|
||||
if (!node.data.configured) {
|
||||
warnings.push(`${node.data.label} is not fully configured`);
|
||||
}
|
||||
if (node.data.errors?.length) {
|
||||
errors.push(...node.data.errors.map(e => `${node.data.label}: ${e}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate edge connections
|
||||
for (const edge of edges) {
|
||||
const source = nodes.find(n => n.id === edge.source);
|
||||
const target = nodes.find(n => n.id === edge.target);
|
||||
|
||||
if (source && target) {
|
||||
const sourceType = source.data.type as NodeType;
|
||||
const targetType = target.data.type as NodeType;
|
||||
const validTargets = VALID_CONNECTIONS[sourceType] || [];
|
||||
|
||||
if (!validTargets.includes(targetType)) {
|
||||
errors.push(
|
||||
`Invalid connection: ${source.data.label} -> ${target.data.label}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check connectivity
|
||||
const objectives = nodes.filter(n => n.data.type === 'objective');
|
||||
for (const obj of objectives) {
|
||||
const hasIncoming = edges.some(e => e.target === obj.id);
|
||||
if (!hasIncoming) {
|
||||
errors.push(`${obj.data.label} has no connected extractor`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user