feat(canvas): Canvas V3 - Bug fixes and study workflow improvements

Bug Fixes:
- Fix Atomizer Assistant error with reconnect button and error state handling
- Enable connection/edge deletion with keyboard Delete/Backspace keys
- Fix drag & drop positioning using screenToFlowPosition correctly
- Fix loadFromConfig to create all node types and edges properly

UI/UX Improvements:
- Minimal responsive header with context breadcrumb
- Better contrast with white text on dark backgrounds
- Larger font sizes in NodePalette for readability
- Study-aware header showing selected study name

New Features:
- Enhanced ExecuteDialog with Create/Update mode toggle
- Select existing study to update or create new study
- Home page Canvas Builder button for quick access
- Home navigation button in CanvasView header

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 11:34:41 -05:00
parent 62f3ab03ba
commit 62284a995e
7 changed files with 525 additions and 173 deletions

View File

@@ -8,6 +8,7 @@ interface CanvasState {
nodes: Node<CanvasNodeData>[];
edges: Edge[];
selectedNode: string | null;
selectedEdge: string | null;
validation: ValidationResult;
// Actions
@@ -17,7 +18,9 @@ interface CanvasState {
addNode: (type: NodeType, position: { x: number; y: number }) => void;
updateNodeData: (nodeId: string, data: Partial<CanvasNodeData>) => void;
selectNode: (nodeId: string | null) => void;
selectEdge: (edgeId: string | null) => void;
deleteSelected: () => void;
deleteEdge: (edgeId: string) => void;
validate: () => ValidationResult;
toIntent: () => OptimizationIntent;
clear: () => void;
@@ -94,6 +97,7 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
nodes: [],
edges: [],
selectedNode: null,
selectedEdge: null,
validation: { valid: false, errors: [], warnings: [] },
onNodesChange: (changes) => {
@@ -129,11 +133,26 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
},
selectNode: (nodeId) => {
set({ selectedNode: nodeId });
set({ selectedNode: nodeId, selectedEdge: null });
},
selectEdge: (edgeId) => {
set({ selectedEdge: edgeId, selectedNode: null });
},
deleteSelected: () => {
const { selectedNode, nodes, edges } = get();
const { selectedNode, selectedEdge, nodes, edges } = get();
// Delete selected edge
if (selectedEdge) {
set({
edges: edges.filter((e) => e.id !== selectedEdge),
selectedEdge: null,
});
return;
}
// Delete selected node
if (!selectedNode) return;
set({
@@ -143,6 +162,13 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
});
},
deleteEdge: (edgeId) => {
set({
edges: get().edges.filter((e) => e.id !== edgeId),
selectedEdge: null,
});
},
validate: () => {
const { nodes, edges } = get();
const result = validateGraph(nodes, edges);
@@ -160,6 +186,7 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
nodes: [],
edges: [],
selectedNode: null,
selectedEdge: null,
validation: { valid: false, errors: [], warnings: [] },
});
nodeIdCounter = 0;
@@ -305,77 +332,177 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
nodes,
edges,
selectedNode: null,
selectedEdge: null,
validation: { valid: false, errors: [], warnings: [] },
});
},
loadFromConfig: (config) => {
// Convert optimization_config.json format to intent format, then load
const intent: OptimizationIntent = {
version: '1.0',
source: 'canvas',
timestamp: new Date().toISOString(),
model: {
path: config.model?.path,
type: config.model?.type,
},
solver: {
type: config.solver?.solution ? `SOL${config.solver.solution}` : undefined,
},
design_variables: (config.design_variables || []).map(dv => ({
name: dv.expression_name || dv.name,
min: dv.lower,
max: dv.upper,
})),
extractors: [], // Will be inferred from objectives
objectives: (config.objectives || []).map(obj => ({
name: obj.name,
direction: (obj.direction as 'minimize' | 'maximize') || 'minimize',
weight: obj.weight || 1,
extractor: obj.extractor || '',
})),
constraints: (config.constraints || []).map(con => ({
name: con.name,
operator: con.type === 'upper' ? '<=' : '>=',
value: con.value || 0,
extractor: con.extractor || '',
})),
optimization: {
method: config.method,
max_trials: config.max_trials,
},
surrogate: config.surrogate ? {
enabled: true,
type: config.surrogate.type,
min_trials: config.surrogate.min_trials,
} : undefined,
// Complete rewrite: Create all nodes and edges directly from config
nodeIdCounter = 0;
const nodes: Node<CanvasNodeData>[] = [];
const edges: Edge[] = [];
// Column positions for proper layout
const COLS = {
modelDvar: 50,
solver: 280,
extractor: 510,
objCon: 740,
algo: 970,
surrogate: 1200,
};
const ROW_HEIGHT = 100;
const START_Y = 50;
// Helper to create node
const createNode = (type: NodeType, x: number, y: number, data: Partial<CanvasNodeData>): string => {
const id = getNodeId();
nodes.push({
id,
type,
position: { x, y },
data: { ...getDefaultData(type), ...data, configured: true } as CanvasNodeData,
});
return id;
};
// 1. Model node
const modelId = createNode('model', COLS.modelDvar, START_Y, {
label: config.study_name || 'Model',
filePath: config.model?.path,
fileType: config.model?.type as 'prt' | 'fem' | 'sim' | undefined,
});
// 2. Solver node
const solverType = config.solver?.solution ? `SOL${config.solver.solution}` : undefined;
const solverId = createNode('solver', COLS.solver, START_Y, {
label: 'Solver',
solverType: solverType as any,
});
edges.push({ id: `e_model_solver`, source: modelId, target: solverId });
// 3. Design variables (column 0, below model)
let dvRow = 1;
for (const dv of config.design_variables || []) {
const dvId = createNode('designVar', COLS.modelDvar, START_Y + dvRow * ROW_HEIGHT, {
label: dv.expression_name || dv.name,
expressionName: dv.expression_name || dv.name,
minValue: dv.lower,
maxValue: dv.upper,
});
edges.push({ id: `e_dv_${dvRow}_model`, source: dvId, target: modelId });
dvRow++;
}
// 4. Extractors - infer from objectives and constraints
const extractorNames: Record<string, string> = {
'E1': 'Displacement', 'E2': 'Frequency', 'E3': 'Solid Stress',
'E4': 'BDF Mass', 'E5': 'CAD Mass', 'E8': 'Zernike (OP2)',
'E9': 'Zernike (CSV)', 'E10': 'Zernike (RMS)',
};
// Infer extractors from objectives and constraints
const extractorIds = new Set<string>();
for (const obj of intent.objectives) {
for (const obj of config.objectives || []) {
if (obj.extractor) extractorIds.add(obj.extractor);
}
for (const con of intent.constraints) {
for (const con of config.constraints || []) {
if (con.extractor) extractorIds.add(con.extractor);
}
const extractorNames: Record<string, string> = {
'E1': 'Displacement',
'E2': 'Frequency',
'E3': 'Solid Stress',
'E4': 'BDF Mass',
'E5': 'CAD Mass',
'E8': 'Zernike (OP2)',
'E9': 'Zernike (CSV)',
'E10': 'Zernike (RMS)',
};
// If no extractors found, add a default based on objectives
if (extractorIds.size === 0 && (config.objectives?.length || 0) > 0) {
extractorIds.add('E5'); // Default to CAD Mass
}
intent.extractors = Array.from(extractorIds).map(id => ({
id,
name: extractorNames[id] || id,
}));
let extRow = 0;
const extractorMap: Record<string, string> = {};
for (const extId of extractorIds) {
const nodeId = createNode('extractor', COLS.extractor, START_Y + extRow * ROW_HEIGHT, {
label: extractorNames[extId] || extId,
extractorId: extId,
extractorName: extractorNames[extId] || extId,
});
extractorMap[extId] = nodeId;
edges.push({ id: `e_solver_ext_${extRow}`, source: solverId, target: nodeId });
extRow++;
}
get().loadFromIntent(intent);
// 5. Objectives
let objRow = 0;
const objIds: string[] = [];
for (const obj of config.objectives || []) {
const objId = createNode('objective', COLS.objCon, START_Y + objRow * ROW_HEIGHT, {
label: obj.name,
name: obj.name,
direction: (obj.direction as 'minimize' | 'maximize') || 'minimize',
weight: obj.weight || 1,
});
objIds.push(objId);
// Connect to extractor
const extNodeId = obj.extractor ? extractorMap[obj.extractor] : Object.values(extractorMap)[0];
if (extNodeId) {
edges.push({ id: `e_ext_obj_${objRow}`, source: extNodeId, target: objId });
}
objRow++;
}
// 6. Constraints
let conRow = objRow;
const conIds: string[] = [];
for (const con of config.constraints || []) {
const conId = createNode('constraint', COLS.objCon, START_Y + conRow * ROW_HEIGHT, {
label: con.name,
name: con.name,
operator: (con.type === 'upper' ? '<=' : '>=') as any,
value: con.value || 0,
});
conIds.push(conId);
// Connect to extractor
const extNodeId = con.extractor ? extractorMap[con.extractor] : Object.values(extractorMap)[0];
if (extNodeId) {
edges.push({ id: `e_ext_con_${conRow}`, source: extNodeId, target: conId });
}
conRow++;
}
// 7. Algorithm node
const method = config.method || (config as any).optimization?.sampler || 'TPE';
const maxTrials = config.max_trials || (config as any).optimization?.n_trials || 100;
const algoId = createNode('algorithm', COLS.algo, START_Y, {
label: 'Algorithm',
method: method as any,
maxTrials: maxTrials,
});
// Connect objectives to algorithm
for (let i = 0; i < objIds.length; i++) {
edges.push({ id: `e_obj_${i}_algo`, source: objIds[i], target: algoId });
}
// Connect constraints to algorithm
for (let i = 0; i < conIds.length; i++) {
edges.push({ id: `e_con_${i}_algo`, source: conIds[i], target: algoId });
}
// 8. Surrogate node (if enabled)
if (config.surrogate) {
const surId = createNode('surrogate', COLS.surrogate, START_Y, {
label: 'Surrogate',
enabled: true,
modelType: config.surrogate.type as any,
minTrials: config.surrogate.min_trials,
});
edges.push({ id: `e_algo_sur`, source: algoId, target: surId });
}
set({
nodes,
edges,
selectedNode: null,
selectedEdge: null,
validation: { valid: false, errors: [], warnings: [] },
});
},
}));