feat(canvas): sync objective/constraint source on edge connect/delete

This commit is contained in:
2026-01-29 02:39:45 +00:00
parent 4a7422c620
commit 00dd88599e

View File

@@ -235,6 +235,7 @@ function SpecRendererInner({
clearSelection,
updateNodePosition,
addNode,
updateNode,
addEdge,
removeEdge,
removeNode,
@@ -521,34 +522,106 @@ function SpecRendererInner({
(changes: EdgeChange[]) => {
if (!editable) return;
const classify = (id: string): string => {
if (id === 'model' || id === 'solver' || id === 'algorithm' || id === 'surrogate') return id;
const prefix = id.split('_')[0];
if (prefix === 'dv') return 'designVar';
if (prefix === 'ext') return 'extractor';
if (prefix === 'obj') return 'objective';
if (prefix === 'con') return 'constraint';
return 'unknown';
};
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);
if (!edge) continue;
const sourceType = classify(edge.source);
const targetType = classify(edge.target);
// First remove the visual edge
removeEdge(edge.source, edge.target).catch((err) => {
console.error('Failed to remove edge:', err);
setError(err.message);
});
// Option A truth model: if we removed Extractor -> Objective/Constraint,
// clear the target's source to avoid stale runnable config.
if (sourceType === 'extractor' && (targetType === 'objective' || targetType === 'constraint')) {
updateNode(edge.target, {
// Setting to an empty object would violate schema; clear to placeholder
// and let validation catch missing wiring.
source: { extractor_id: '', output_name: '' },
}).catch((err) => {
console.error('Failed to clear source on node:', err);
setError(err.message);
});
}
}
}
},
[editable, edges, removeEdge, setError]
[editable, edges, removeEdge, setError, updateNode]
);
// Handle new connections
const onConnect = useCallback(
(connection: Connection) => {
async (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);
});
const sourceId = connection.source;
const targetId = connection.target;
// Helper: classify nodes by ID (synthetic vs spec-backed)
const classify = (id: string): string => {
if (id === 'model' || id === 'solver' || id === 'algorithm' || id === 'surrogate') return id;
const prefix = id.split('_')[0];
if (prefix === 'dv') return 'designVar';
if (prefix === 'ext') return 'extractor';
if (prefix === 'obj') return 'objective';
if (prefix === 'con') return 'constraint';
return 'unknown';
};
const sourceType = classify(sourceId);
const targetType = classify(targetId);
try {
// Always persist the visual edge (for now)
await addEdge(sourceId, targetId);
// Option A truth model: objective/constraint source is the real linkage.
// When user connects Extractor -> Objective/Constraint, update *.source accordingly.
if (spec && sourceType === 'extractor' && (targetType === 'objective' || targetType === 'constraint')) {
const ext = spec.extractors.find((e) => e.id === sourceId);
// Choose a sensible default output:
// - prefer 'value' if present
// - else if only one output, use it
// - else use first output
const outputs = ext?.outputs || [];
const preferred = outputs.find((o) => o.name === 'value')?.name;
const outputName =
preferred || (outputs.length === 1 ? outputs[0].name : outputs.length > 0 ? outputs[0].name : 'value');
if (targetType === 'objective') {
await updateNode(targetId, {
source: { extractor_id: sourceId, output_name: outputName },
});
} else {
await updateNode(targetId, {
source: { extractor_id: sourceId, output_name: outputName },
});
}
}
} catch (err) {
console.error('Failed to add connection:', err);
setError(err instanceof Error ? err.message : 'Failed to add connection');
}
},
[editable, addEdge, setError]
[editable, addEdge, setError, spec, updateNode]
);
// Handle node clicks for selection