add folder, file nodes and major usability improvements
This commit is contained in:
@@ -16,18 +16,27 @@ import { embedWorkflow, extractWorkflow } from './pngMetadata';
|
||||
import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture';
|
||||
import { hydrateWorkflowState } from './workflowHydration';
|
||||
import { serializeWorkflowState } from './workflowSerialization';
|
||||
import { loadDefaultWorkflowAsset } from './defaultWorkflow';
|
||||
import {
|
||||
serializeExecutionGraph,
|
||||
getAutoRunnableNodes,
|
||||
hasBlockingAutoRunInput,
|
||||
} from './executionGraph';
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────
|
||||
|
||||
const DATA_TYPES = new Set([
|
||||
'DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'ANY_TABLE',
|
||||
'COORD', 'STATS_SOURCE', 'VALUE_SOURCE',
|
||||
'COORD', 'STATS_SOURCE', 'VALUE_SOURCE', 'COLORMAP', 'SAVE_LAYER', 'FONT', 'FILE_PATH', 'DIRECTORY',
|
||||
]);
|
||||
|
||||
const SOCKET_COMPATIBILITY = {
|
||||
STATS_SOURCE: new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'RECORD_TABLE']),
|
||||
ANY_TABLE: new Set(['MEASURE_TABLE', 'RECORD_TABLE']),
|
||||
VALUE_SOURCE: new Set(['FLOAT', 'MEASURE_TABLE']),
|
||||
SAVE_LAYER: new Set(['DATA_FIELD', 'IMAGE']),
|
||||
FLOAT: new Set(['INT']),
|
||||
INT: new Set(['FLOAT']),
|
||||
};
|
||||
|
||||
const TYPE_COLORS = {
|
||||
@@ -39,8 +48,14 @@ const TYPE_COLORS = {
|
||||
ANY_TABLE: '#67e8f9',
|
||||
COORD: '#e91ed1',
|
||||
FLOAT: '#7dd3fc',
|
||||
INT: '#38bdf8',
|
||||
STATS_SOURCE:'#c084fc',
|
||||
VALUE_SOURCE:'#60a5fa',
|
||||
COLORMAP: '#f472b6',
|
||||
SAVE_LAYER: '#22c55e',
|
||||
FONT: '#fb7185',
|
||||
FILE_PATH: '#f59e0b',
|
||||
DIRECTORY: '#f97316',
|
||||
};
|
||||
|
||||
const NODE_TYPES = { custom: CustomNode };
|
||||
@@ -59,6 +74,12 @@ function getOutputSlot(handleId) {
|
||||
return parseInt(handleId.split('::')[1], 10);
|
||||
}
|
||||
|
||||
function sameStringArray(a = [], b = []) {
|
||||
if (a === b) return true;
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
||||
return a.every((item, index) => item === b[index]);
|
||||
}
|
||||
|
||||
function socketTypesCompatible(sourceType, targetType) {
|
||||
if (sourceType === targetType) return true;
|
||||
const accepted = SOCKET_COMPATIBILITY[targetType];
|
||||
@@ -221,43 +242,6 @@ async function captureViewportBlob(viewportEl, options) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Graph serialisation → backend prompt format ───────────────────────
|
||||
|
||||
function serializeGraph(nodes, edges, { excludeManualTrigger = false } = {}) {
|
||||
const prompt = {};
|
||||
|
||||
for (const node of nodes) {
|
||||
const { className, definition, widgetValues } = node.data;
|
||||
if (!definition) continue;
|
||||
if (excludeManualTrigger && definition.manual_trigger) continue;
|
||||
|
||||
const inputs = {};
|
||||
|
||||
// Widget (scalar) values
|
||||
const required = definition.input.required || {};
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const [type] = Array.isArray(spec) ? spec : [spec];
|
||||
if (DATA_TYPES.has(type)) continue; // socket, handled via edges
|
||||
if (type === 'BUTTON') continue; // UI-only widget, not a backend input
|
||||
if (widgetValues[name] !== undefined) {
|
||||
inputs[name] = widgetValues[name];
|
||||
}
|
||||
}
|
||||
|
||||
// Connected (socket) inputs from edges
|
||||
const incoming = edges.filter((e) => e.target === node.id);
|
||||
for (const edge of incoming) {
|
||||
const inputName = getInputName(edge.targetHandle);
|
||||
const outputSlot = getOutputSlot(edge.sourceHandle);
|
||||
inputs[inputName] = [edge.source, outputSlot];
|
||||
}
|
||||
|
||||
prompt[node.id] = { class_type: className, inputs };
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
// ── Context menu component ────────────────────────────────────────────
|
||||
|
||||
function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirection }) {
|
||||
@@ -461,25 +445,15 @@ function Flow() {
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
const [status, setStatus] = useState({ text: 'Connecting…', level: 'info' });
|
||||
const [contextMenu, setContextMenu] = useState(null);
|
||||
const [fileBrowserCb, setFileBrowserCb] = useState(null);
|
||||
const [fileBrowserState, setFileBrowserState] = useState(null);
|
||||
|
||||
const nodeDefsRef = useRef({});
|
||||
const nextIdRef = useRef(1);
|
||||
const autoRunTimer = useRef(null);
|
||||
const autoRunRef = useRef(null);
|
||||
const defaultWorkflowLoadAttemptedRef = useRef(false);
|
||||
const reactFlow = useReactFlow();
|
||||
|
||||
// ── Load node definitions ───────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
api.getNodes().then((defs) => {
|
||||
nodeDefsRef.current = defs;
|
||||
setStatus({ text: `Loaded ${Object.keys(defs).length} nodes.`, level: 'info' });
|
||||
}).catch((err) => {
|
||||
setStatus({ text: 'Failed to load nodes: ' + err.message, level: 'error' });
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── WebSocket ───────────────────────────────────────────────────────
|
||||
|
||||
const updateNodeData = useCallback((nodeId, patch) => {
|
||||
@@ -488,6 +462,96 @@ function Flow() {
|
||||
));
|
||||
}, [setNodes]);
|
||||
|
||||
const setNodeOutputs = useCallback((nodeId, output, outputName, extraDefinitionPatch = {}) => {
|
||||
setNodes((prev) => prev.map((node) => {
|
||||
if (node.id !== nodeId) return node;
|
||||
const currentDefinition = node.data.definition || {};
|
||||
const nextDefinition = {
|
||||
...currentDefinition,
|
||||
...extraDefinitionPatch,
|
||||
output,
|
||||
output_name: outputName,
|
||||
};
|
||||
const sameOutputs = sameStringArray(currentDefinition.output, output);
|
||||
const sameNames = sameStringArray(currentDefinition.output_name, outputName);
|
||||
const sameOutputPaths = sameStringArray(currentDefinition.output_paths, nextDefinition.output_paths);
|
||||
if (sameOutputs && sameNames && sameOutputPaths) {
|
||||
return node;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
definition: nextDefinition,
|
||||
},
|
||||
};
|
||||
}));
|
||||
reactFlow.updateNodeInternals(nodeId);
|
||||
}, [reactFlow, setNodes]);
|
||||
|
||||
const getResolvedPathInput = useCallback((nodeId) => {
|
||||
const edge = reactFlow.getEdges().find(
|
||||
(e) => e.target === nodeId && getInputName(e.targetHandle) === 'path'
|
||||
);
|
||||
if (!edge) return null;
|
||||
const sourceNode = reactFlow.getNode(edge.source);
|
||||
const outputPaths = sourceNode?.data?.definition?.output_paths;
|
||||
const outputSlot = getOutputSlot(edge.sourceHandle);
|
||||
if (Array.isArray(outputPaths) && typeof outputPaths[outputSlot] === 'string') {
|
||||
return outputPaths[outputSlot];
|
||||
}
|
||||
return null;
|
||||
}, [reactFlow]);
|
||||
|
||||
const refreshLoadNodeOutputs = useCallback(async (nodeId, explicitPath = null) => {
|
||||
const node = reactFlow.getNode(nodeId);
|
||||
if (!node) return;
|
||||
|
||||
let resolvedPath = typeof explicitPath === 'string' && explicitPath ? explicitPath : null;
|
||||
if (!resolvedPath) {
|
||||
resolvedPath = getResolvedPathInput(nodeId);
|
||||
}
|
||||
if (!resolvedPath) {
|
||||
if (node.data.className === 'LoadFile') {
|
||||
resolvedPath = node.data.widgetValues?.filename || '';
|
||||
} else if (node.data.className === 'LoadDemo') {
|
||||
resolvedPath = node.data.widgetValues?.name || '';
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedPath) {
|
||||
setNodeOutputs(nodeId, ['DATA_FIELD'], ['field'], { output_paths: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const channels = await api.getChannels(resolvedPath);
|
||||
setNodeOutputs(
|
||||
nodeId,
|
||||
channels.map((channel) => channel.type),
|
||||
channels.map((channel) => channel.name),
|
||||
{ output_paths: [] },
|
||||
);
|
||||
}, [getResolvedPathInput, reactFlow, setNodeOutputs]);
|
||||
|
||||
const refreshFolderNodeOutputs = useCallback(async (nodeId, folderPath) => {
|
||||
const entries = folderPath ? await api.getFolderFiles(folderPath) : [];
|
||||
setNodeOutputs(
|
||||
nodeId,
|
||||
entries.map((entry) => entry.type),
|
||||
entries.map((entry) => entry.name),
|
||||
{ output_paths: entries.map((entry) => entry.path) },
|
||||
);
|
||||
|
||||
const downstreamPathEdges = reactFlow.getEdges().filter(
|
||||
(edge) => edge.source === nodeId && getInputName(edge.targetHandle) === 'path'
|
||||
);
|
||||
for (const edge of downstreamPathEdges) {
|
||||
const outputSlot = getOutputSlot(edge.sourceHandle);
|
||||
const resolvedPath = entries[outputSlot]?.path || null;
|
||||
await refreshLoadNodeOutputs(edge.target, resolvedPath);
|
||||
}
|
||||
}, [reactFlow, refreshLoadNodeOutputs, setNodeOutputs]);
|
||||
|
||||
useEffect(() => {
|
||||
api.setMessageHandler((msg) => {
|
||||
console.log('[argonode] WS:', msg.type, msg.data?.node_id || msg.data?.node || '');
|
||||
@@ -532,7 +596,7 @@ function Flow() {
|
||||
case 'overlay':
|
||||
updateNodeData(
|
||||
msg.data.node_id,
|
||||
msg.data.overlay?.kind === 'mask_paint'
|
||||
msg.data.overlay?.kind === 'mask_paint' || msg.data.overlay?.kind === 'markup'
|
||||
? { overlay: msg.data.overlay, previewImage: null }
|
||||
: { overlay: msg.data.overlay },
|
||||
);
|
||||
@@ -568,8 +632,36 @@ function Flow() {
|
||||
filtered
|
||||
);
|
||||
});
|
||||
if (getInputName(params.targetHandle) === 'path') {
|
||||
setTimeout(() => {
|
||||
refreshLoadNodeOutputs(params.target);
|
||||
}, 0);
|
||||
}
|
||||
scheduleAutoRun();
|
||||
}, [setEdges]);
|
||||
}, [refreshLoadNodeOutputs, setEdges]); // scheduleAutoRun is stable (no deps)
|
||||
|
||||
const handleEdgesChange = useCallback((changes) => {
|
||||
const currentEdges = reactFlow.getEdges();
|
||||
onEdgesChange(changes);
|
||||
|
||||
const affectedPathTargets = new Set();
|
||||
for (const change of changes) {
|
||||
if (change.type !== 'remove') continue;
|
||||
const removedEdge = currentEdges.find((edge) => edge.id === change.id);
|
||||
if (!removedEdge) continue;
|
||||
if (getInputName(removedEdge.targetHandle) === 'path') {
|
||||
affectedPathTargets.add(removedEdge.target);
|
||||
}
|
||||
}
|
||||
|
||||
if (affectedPathTargets.size > 0) {
|
||||
setTimeout(() => {
|
||||
affectedPathTargets.forEach((nodeId) => {
|
||||
refreshLoadNodeOutputs(nodeId);
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
}, [onEdgesChange, reactFlow, refreshLoadNodeOutputs]);
|
||||
|
||||
// ── Drop-on-blank: open filtered context menu ──────────────────────
|
||||
|
||||
@@ -610,44 +702,35 @@ function Flow() {
|
||||
};
|
||||
}));
|
||||
|
||||
// If this is a filename/name change on a LoadFile/LoadDemo node, fetch channels
|
||||
if ((name === 'filename' || name === 'name') && value) {
|
||||
const node = reactFlow.getNode(nodeId);
|
||||
if (node && (node.data.className === 'LoadFile' || node.data.className === 'LoadDemo')) {
|
||||
api.getChannels(value).then((channels) => {
|
||||
setNodes((prev) => prev.map((n) => {
|
||||
if (n.id !== nodeId) return n;
|
||||
return {
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
definition: {
|
||||
...n.data.definition,
|
||||
output: channels.map((c) => c.type),
|
||||
output_name: channels.map((c) => c.name),
|
||||
},
|
||||
},
|
||||
};
|
||||
}));
|
||||
reactFlow.updateNodeInternals(nodeId);
|
||||
});
|
||||
}
|
||||
const node = reactFlow.getNode(nodeId);
|
||||
if (node && node.data.className === 'Folder' && name === 'folder') {
|
||||
refreshFolderNodeOutputs(nodeId, value);
|
||||
}
|
||||
|
||||
if (node && (node.data.className === 'LoadFile' || node.data.className === 'LoadDemo') && (name === 'filename' || name === 'name')) {
|
||||
refreshLoadNodeOutputs(nodeId, value);
|
||||
}
|
||||
|
||||
scheduleAutoRun();
|
||||
}, [setNodes]); // scheduleAutoRun is stable (no deps)
|
||||
}, [reactFlow, refreshFolderNodeOutputs, refreshLoadNodeOutputs, setNodes]); // scheduleAutoRun is stable (no deps)
|
||||
|
||||
// ── File browser ────────────────────────────────────────────────────
|
||||
|
||||
const openFileBrowser = useCallback((callback) => {
|
||||
const openFileBrowser = useCallback((callback, { selectionMode = 'file' } = {}) => {
|
||||
if (selectionMode === 'folder' && window.pywebview?.api?.open_folder_dialog) {
|
||||
window.pywebview.api.open_folder_dialog().then((path) => {
|
||||
if (path) callback(path);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Use native file picker when running inside pywebview (desktop app)
|
||||
if (window.pywebview?.api?.open_file_dialog) {
|
||||
if (selectionMode === 'file' && window.pywebview?.api?.open_file_dialog) {
|
||||
window.pywebview.api.open_file_dialog().then((path) => {
|
||||
if (path) callback(path);
|
||||
});
|
||||
return;
|
||||
}
|
||||
setFileBrowserCb(() => callback);
|
||||
setFileBrowserState({ callback, selectionMode });
|
||||
}, []);
|
||||
|
||||
// ── Node context value (stable) ─────────────────────────────────────
|
||||
@@ -656,7 +739,7 @@ function Flow() {
|
||||
const currentNodes = reactFlow.getNodes();
|
||||
const currentEdges = reactFlow.getEdges();
|
||||
// Include ALL nodes (no excludeManualTrigger) so the save node is in the prompt
|
||||
const prompt = serializeGraph(currentNodes, currentEdges);
|
||||
const prompt = serializeExecutionGraph(currentNodes, currentEdges);
|
||||
if (!prompt || Object.keys(prompt).length === 0) return;
|
||||
setStatus({ text: 'Saving…', level: 'info' });
|
||||
api.runPrompt(prompt).catch((err) => {
|
||||
@@ -715,25 +798,17 @@ function Flow() {
|
||||
|
||||
setNodes((ns) => [...ns, newNode]);
|
||||
|
||||
// Initialize dynamic outputs for nodes that depend on the selected path/folder.
|
||||
if (className === 'Folder' && widgetValues.folder) {
|
||||
refreshFolderNodeOutputs(newNodeId, widgetValues.folder);
|
||||
}
|
||||
|
||||
// For LoadFile/LoadDemo, auto-fetch channels for the default value
|
||||
if (className === 'LoadDemo' && widgetValues.name) {
|
||||
api.getChannels(widgetValues.name).then((channels) => {
|
||||
setNodes((prev) => prev.map((n) => {
|
||||
if (n.id !== newNodeId) return n;
|
||||
return {
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
definition: {
|
||||
...n.data.definition,
|
||||
output: channels.map((c) => c.type),
|
||||
output_name: channels.map((c) => c.name),
|
||||
},
|
||||
},
|
||||
};
|
||||
}));
|
||||
reactFlow.updateNodeInternals(newNodeId);
|
||||
});
|
||||
refreshLoadNodeOutputs(newNodeId, widgetValues.name);
|
||||
}
|
||||
if (className === 'LoadFile' && widgetValues.filename) {
|
||||
refreshLoadNodeOutputs(newNodeId, widgetValues.filename);
|
||||
}
|
||||
|
||||
// Auto-connect if this was triggered by dropping a connection on blank space
|
||||
@@ -783,7 +858,7 @@ function Flow() {
|
||||
|
||||
setContextMenu(null);
|
||||
scheduleAutoRun();
|
||||
}, [contextMenu, reactFlow, setNodes, setEdges]);
|
||||
}, [contextMenu, reactFlow, refreshFolderNodeOutputs, refreshLoadNodeOutputs, setNodes, setEdges]); // scheduleAutoRun is stable (no deps)
|
||||
|
||||
// ── Toolbar actions ─────────────────────────────────────────────────
|
||||
|
||||
@@ -791,7 +866,7 @@ function Flow() {
|
||||
// Read current state via functional ref to avoid stale closure
|
||||
const currentNodes = reactFlow.getNodes();
|
||||
const currentEdges = reactFlow.getEdges();
|
||||
const prompt = serializeGraph(currentNodes, currentEdges);
|
||||
const prompt = serializeExecutionGraph(currentNodes, currentEdges);
|
||||
|
||||
if (!prompt || Object.keys(prompt).length === 0) {
|
||||
setStatus({ text: 'Graph is empty — add some nodes first.', level: 'error' });
|
||||
@@ -809,28 +884,15 @@ function Flow() {
|
||||
autoRunRef.current = () => {
|
||||
const currentNodes = reactFlow.getNodes();
|
||||
const currentEdges = reactFlow.getEdges();
|
||||
const runnableNodes = getAutoRunnableNodes(currentNodes, currentEdges);
|
||||
|
||||
// Don't run if any non-manual node has unconnected required data inputs
|
||||
// or any FILE_PICKER widget is empty
|
||||
for (const node of currentNodes) {
|
||||
const def = node.data?.definition;
|
||||
if (!def || def.manual_trigger) continue; // skip manual-trigger nodes
|
||||
const required = def.input.required || {};
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const [type] = Array.isArray(spec) ? spec : [spec];
|
||||
if (type === 'FILE_PICKER') {
|
||||
if (!node.data.widgetValues?.[name]) return; // no file selected, skip
|
||||
continue;
|
||||
}
|
||||
if (!DATA_TYPES.has(type)) continue;
|
||||
const hasEdge = currentEdges.some(
|
||||
(e) => e.target === node.id && getInputName(e.targetHandle) === name
|
||||
);
|
||||
if (!hasEdge) return; // incomplete graph, skip auto-run
|
||||
}
|
||||
for (const node of runnableNodes) {
|
||||
if (hasBlockingAutoRunInput(node, currentEdges)) return;
|
||||
}
|
||||
|
||||
const prompt = serializeGraph(currentNodes, currentEdges, { excludeManualTrigger: true });
|
||||
const prompt = serializeExecutionGraph(currentNodes, currentEdges, { excludeManualTrigger: true });
|
||||
if (!prompt || Object.keys(prompt).length === 0) return;
|
||||
setStatus({ text: 'Running…', level: 'info' });
|
||||
api.runPrompt(prompt).catch((err) => {
|
||||
@@ -855,7 +917,57 @@ function Flow() {
|
||||
setNodes(hydrated.nodes);
|
||||
setEdges(hydrated.edges);
|
||||
nextIdRef.current = hydrated.nextNodeId;
|
||||
}, [setNodes, setEdges]);
|
||||
setTimeout(() => {
|
||||
hydrated.nodes.forEach((node) => {
|
||||
if (node.data.className === 'Folder' && node.data.widgetValues?.folder) {
|
||||
refreshFolderNodeOutputs(node.id, node.data.widgetValues.folder);
|
||||
}
|
||||
});
|
||||
hydrated.nodes.forEach((node) => {
|
||||
if (node.data.className === 'LoadFile' || node.data.className === 'LoadDemo') {
|
||||
refreshLoadNodeOutputs(node.id);
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
}, [refreshFolderNodeOutputs, refreshLoadNodeOutputs, setNodes, setEdges]);
|
||||
|
||||
const loadDefaultWorkflow = useCallback(async () => {
|
||||
if (defaultWorkflowLoadAttemptedRef.current) return;
|
||||
defaultWorkflowLoadAttemptedRef.current = true;
|
||||
|
||||
const graphHasContent = () => {
|
||||
const currentNodes = reactFlow.getNodes();
|
||||
const currentEdges = reactFlow.getEdges();
|
||||
return currentNodes.length > 0 || currentEdges.length > 0;
|
||||
};
|
||||
|
||||
if (graphHasContent()) return;
|
||||
|
||||
try {
|
||||
const loaded = await loadDefaultWorkflowAsset();
|
||||
if (!loaded || graphHasContent()) return;
|
||||
|
||||
applyWorkflowData(loaded.workflow);
|
||||
setStatus({ text: `Loaded default workflow from ${loaded.source}.`, level: 'info' });
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => scheduleAutoRun());
|
||||
});
|
||||
} catch (err) {
|
||||
setStatus({ text: 'Default workflow failed to load: ' + err.message, level: 'error' });
|
||||
}
|
||||
}, [applyWorkflowData, reactFlow, scheduleAutoRun]);
|
||||
|
||||
// ── Load node definitions ───────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
api.getNodes().then((defs) => {
|
||||
nodeDefsRef.current = defs;
|
||||
setStatus({ text: `Loaded ${Object.keys(defs).length} nodes.`, level: 'info' });
|
||||
loadDefaultWorkflow();
|
||||
}).catch((err) => {
|
||||
setStatus({ text: 'Failed to load nodes: ' + err.message, level: 'error' });
|
||||
});
|
||||
}, [loadDefaultWorkflow]);
|
||||
|
||||
const getWorkflowBlob = useCallback(async () => {
|
||||
const viewportEl = document.querySelector('.react-flow__viewport');
|
||||
@@ -1112,7 +1224,7 @@ function Flow() {
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onConnectEnd={onConnectEnd}
|
||||
isValidConnection={isValidConnection}
|
||||
@@ -1150,10 +1262,11 @@ function Flow() {
|
||||
</div>
|
||||
|
||||
{/* File browser modal */}
|
||||
{fileBrowserCb && (
|
||||
{fileBrowserState && (
|
||||
<FileBrowser
|
||||
onSelect={(path) => { fileBrowserCb(path); setFileBrowserCb(null); }}
|
||||
onClose={() => setFileBrowserCb(null)}
|
||||
selectionMode={fileBrowserState.selectionMode}
|
||||
onSelect={(path) => { fileBrowserState.callback(path); setFileBrowserState(null); }}
|
||||
onClose={() => setFileBrowserState(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,14 +6,15 @@ const SurfaceView = lazy(() => import('./SurfaceView'));
|
||||
const CrossSectionOverlay = lazy(() => import('./CrossSectionOverlay'));
|
||||
const CropBoxOverlay = lazy(() => import('./CropBoxOverlay'));
|
||||
const MaskPaintOverlay = lazy(() => import('./MaskPaintOverlay'));
|
||||
const MarkupOverlay = lazy(() => import('./MarkupOverlay'));
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────
|
||||
|
||||
const DATA_TYPES = new Set([
|
||||
'DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'ANY_TABLE',
|
||||
'COORD', 'STATS_SOURCE', 'VALUE_SOURCE',
|
||||
'COORD', 'STATS_SOURCE', 'VALUE_SOURCE', 'COLORMAP', 'SAVE_LAYER', 'FONT', 'FILE_PATH', 'DIRECTORY',
|
||||
]);
|
||||
const SOCKET_WIDGET_TYPES = new Set(['FLOAT']);
|
||||
const SOCKET_WIDGET_TYPES = new Set(['FLOAT', 'INT']);
|
||||
|
||||
const TYPE_COLORS = {
|
||||
DATA_FIELD: '#3a7abf',
|
||||
@@ -24,8 +25,14 @@ const TYPE_COLORS = {
|
||||
ANY_TABLE: '#67e8f9',
|
||||
COORD: '#e91e63',
|
||||
FLOAT: '#7dd3fc',
|
||||
INT: '#38bdf8',
|
||||
STATS_SOURCE:'#c084fc',
|
||||
VALUE_SOURCE:'#60a5fa',
|
||||
COLORMAP: '#f472b6',
|
||||
SAVE_LAYER: '#22c55e',
|
||||
FONT: '#fb7185',
|
||||
FILE_PATH: '#f59e0b',
|
||||
DIRECTORY: '#f97316',
|
||||
};
|
||||
|
||||
const CAT_COLORS = {
|
||||
@@ -128,6 +135,21 @@ function DraggableNumber({ value, step, min, max, precision, onChange }) {
|
||||
}
|
||||
}, [display]);
|
||||
|
||||
const onWheel = useCallback((e) => {
|
||||
if (editing) return;
|
||||
e.preventDefault();
|
||||
|
||||
const baseStep = Number(step) || 1;
|
||||
const multiplier = e.shiftKey ? 10 : 1;
|
||||
const delta = (e.deltaY < 0 ? 1 : -1) * baseStep * multiplier;
|
||||
const startVal = Number(value);
|
||||
const raw = (Number.isFinite(startVal) ? startVal : 0) + delta;
|
||||
const rounded = precision != null
|
||||
? parseFloat(raw.toFixed(precision))
|
||||
: Math.round(raw);
|
||||
onChange(clamp(rounded));
|
||||
}, [editing, step, value, precision, onChange, clamp]);
|
||||
|
||||
const commitEdit = useCallback(() => {
|
||||
setEditing(false);
|
||||
const parsed = parseFloat(editText);
|
||||
@@ -155,6 +177,7 @@ function DraggableNumber({ value, step, min, max, precision, onChange }) {
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onWheel={onWheel}
|
||||
>
|
||||
<span className="drag-number-val">{display}</span>
|
||||
</div>
|
||||
@@ -179,6 +202,57 @@ function CollapsibleSection({ title, defaultOpen, children }) {
|
||||
);
|
||||
}
|
||||
|
||||
function LayerGalleryPreview({ overlay }) {
|
||||
const layers = Array.isArray(overlay?.layers) ? overlay.layers : [];
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setIndex(0);
|
||||
}, [overlay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (layers.length === 0) {
|
||||
setIndex(0);
|
||||
return;
|
||||
}
|
||||
if (index >= layers.length) {
|
||||
setIndex(layers.length - 1);
|
||||
}
|
||||
}, [index, layers.length]);
|
||||
|
||||
if (layers.length === 0) return null;
|
||||
|
||||
const active = layers[index] || layers[0];
|
||||
|
||||
return (
|
||||
<div className="layer-gallery">
|
||||
<div className="layer-gallery-toolbar">
|
||||
<button
|
||||
className="layer-gallery-btn nodrag"
|
||||
onClick={() => setIndex((current) => (current - 1 + layers.length) % layers.length)}
|
||||
>
|
||||
{'<'}
|
||||
</button>
|
||||
<div className="layer-gallery-name" title={active.name || `Layer ${index + 1}`}>
|
||||
{active.name || `Layer ${index + 1}`}
|
||||
</div>
|
||||
<button
|
||||
className="layer-gallery-btn nodrag"
|
||||
onClick={() => setIndex((current) => (current + 1) % layers.length)}
|
||||
>
|
||||
{'>'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="layer-gallery-count">
|
||||
{index + 1} / {layers.length}
|
||||
</div>
|
||||
<div className="node-preview">
|
||||
<img src={active.image} alt={active.name || `layer ${index + 1}`} draggable={false} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTableColumns(rows) {
|
||||
const columns = [];
|
||||
for (const row of rows) {
|
||||
@@ -352,6 +426,28 @@ function getSourceNodeForInput(store, nodeId, inputName) {
|
||||
return store.nodeLookup?.get(edge.source) || store.nodes?.find((n) => n.id === edge.source) || null;
|
||||
}
|
||||
|
||||
function getConnectedOutputInfo(store, nodeId, inputName) {
|
||||
const targetHandle = `input::${inputName}::`;
|
||||
const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle));
|
||||
if (!edge?.sourceHandle) return null;
|
||||
const sourceNode = store.nodeLookup?.get(edge.source) || store.nodes?.find((n) => n.id === edge.source) || null;
|
||||
const slot = Number.parseInt(edge.sourceHandle.split('::')[1], 10);
|
||||
if (!sourceNode || !Number.isInteger(slot)) return null;
|
||||
return {
|
||||
path: sourceNode.data?.definition?.output_paths?.[slot] || null,
|
||||
name: sourceNode.data?.definition?.output_name?.[slot] || null,
|
||||
};
|
||||
}
|
||||
|
||||
function getBasename(value) {
|
||||
if (typeof value !== 'string') return '';
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return '';
|
||||
const normalized = trimmed.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const parts = normalized.split('/');
|
||||
return parts[parts.length - 1] || '';
|
||||
}
|
||||
|
||||
function getWidgetSourceInputName(opts) {
|
||||
return opts?.source_type_input
|
||||
|| opts?.choices_from_table_input
|
||||
@@ -368,6 +464,197 @@ function widgetVisibleForSourceType(widget, sourceType) {
|
||||
return allowed.includes(sourceType);
|
||||
}
|
||||
|
||||
function widgetVisibleForWidgetValues(widget, widgetValues) {
|
||||
const rules = widget?.opts?.show_when_widget_value;
|
||||
if (!rules || typeof rules !== 'object') return true;
|
||||
|
||||
for (const [widgetName, allowedValues] of Object.entries(rules)) {
|
||||
const allowed = Array.isArray(allowedValues) ? allowedValues.map(String) : [];
|
||||
if (allowed.length === 0) continue;
|
||||
if (!allowed.includes(String(widgetValues?.[widgetName] ?? ''))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function widgetHiddenByConnectedInput(widget, connectedInputs) {
|
||||
const raw = widget?.opts?.hide_when_input_connected;
|
||||
if (!raw || !connectedInputs) return false;
|
||||
const inputs = Array.isArray(raw) ? raw : [raw];
|
||||
return inputs.some((inputName) => connectedInputs.has(String(inputName)));
|
||||
}
|
||||
|
||||
function widgetVisibleForInputVisibility(widget, visibleInputs) {
|
||||
const raw = widget?.opts?.show_when_input_visible;
|
||||
if (!raw) return true;
|
||||
const inputs = Array.isArray(raw) ? raw : [raw];
|
||||
return inputs.some((inputName) => visibleInputs?.has(String(inputName)));
|
||||
}
|
||||
|
||||
function getWidgetInlineInputName(widget) {
|
||||
const raw = widget?.opts?.inline_with_input;
|
||||
if (!raw) return null;
|
||||
return String(Array.isArray(raw) ? raw[0] : raw);
|
||||
}
|
||||
|
||||
const DEFAULT_COLORMAP_STOPS = [
|
||||
{ position: 0, color: '#440154' },
|
||||
{ position: 1, color: '#fde725' },
|
||||
];
|
||||
|
||||
function normalizeHexColor(color, fallback = '#000000') {
|
||||
if (typeof color !== 'string') return fallback;
|
||||
let text = color.trim();
|
||||
if (text.startsWith('#') && text.length === 4) {
|
||||
text = `#${text.slice(1).split('').map((ch) => `${ch}${ch}`).join('')}`;
|
||||
}
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(text)) {
|
||||
return text.toLowerCase();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parseColorMapStops(raw) {
|
||||
let parsed = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = DEFAULT_COLORMAP_STOPS;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
parsed = DEFAULT_COLORMAP_STOPS;
|
||||
}
|
||||
|
||||
const stops = parsed
|
||||
.map((stop) => {
|
||||
const position = Number(stop?.position);
|
||||
return {
|
||||
position: Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0,
|
||||
color: normalizeHexColor(stop?.color, '#000000'),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.position - b.position);
|
||||
|
||||
if (stops.length < 2) {
|
||||
return DEFAULT_COLORMAP_STOPS.map((stop) => ({ ...stop }));
|
||||
}
|
||||
|
||||
stops[0].position = 0;
|
||||
stops[stops.length - 1].position = 1;
|
||||
return stops;
|
||||
}
|
||||
|
||||
function serializeColorMapStops(stops) {
|
||||
return JSON.stringify(stops.map((stop, index) => ({
|
||||
position: index === 0 ? 0 : index === stops.length - 1 ? 1 : Number(stop.position.toFixed(4)),
|
||||
color: normalizeHexColor(stop.color, '#000000'),
|
||||
})));
|
||||
}
|
||||
|
||||
function colorMapGradient(stops) {
|
||||
return `linear-gradient(90deg, ${stops.map((stop) => `${stop.color} ${Math.round(stop.position * 1000) / 10}%`).join(', ')})`;
|
||||
}
|
||||
|
||||
function ColorMapStopsEditor({ nodeId, name, value, onChange }) {
|
||||
const stops = parseColorMapStops(value);
|
||||
|
||||
const commitStops = useCallback((nextStops) => {
|
||||
const ordered = [...nextStops].sort((a, b) => a.position - b.position);
|
||||
if (ordered.length < 2) return;
|
||||
ordered[0] = { ...ordered[0], position: 0 };
|
||||
ordered[ordered.length - 1] = { ...ordered[ordered.length - 1], position: 1 };
|
||||
onChange(nodeId, name, serializeColorMapStops(ordered));
|
||||
}, [name, nodeId, onChange]);
|
||||
|
||||
const updateStop = useCallback((index, patch) => {
|
||||
const next = stops.map((stop, stopIndex) => (stopIndex === index ? { ...stop, ...patch } : { ...stop }));
|
||||
if (index > 0 && index < next.length - 1) {
|
||||
const prev = next[index - 1].position + 0.001;
|
||||
const after = next[index + 1].position - 0.001;
|
||||
next[index].position = Math.max(prev, Math.min(after, next[index].position));
|
||||
}
|
||||
commitStops(next);
|
||||
}, [commitStops, stops]);
|
||||
|
||||
const removeStop = useCallback((index) => {
|
||||
if (stops.length <= 2) return;
|
||||
commitStops(stops.filter((_, stopIndex) => stopIndex !== index));
|
||||
}, [commitStops, stops]);
|
||||
|
||||
const addStop = useCallback(() => {
|
||||
let gapIndex = 0;
|
||||
let gapSize = -1;
|
||||
for (let i = 0; i < stops.length - 1; i += 1) {
|
||||
const gap = stops[i + 1].position - stops[i].position;
|
||||
if (gap > gapSize) {
|
||||
gapIndex = i;
|
||||
gapSize = gap;
|
||||
}
|
||||
}
|
||||
|
||||
const left = stops[gapIndex];
|
||||
const right = stops[gapIndex + 1];
|
||||
const newStop = {
|
||||
position: Number((((left.position + right.position) / 2)).toFixed(4)),
|
||||
color: left.color,
|
||||
};
|
||||
const next = [...stops];
|
||||
next.splice(gapIndex + 1, 0, newStop);
|
||||
commitStops(next);
|
||||
}, [commitStops, stops]);
|
||||
|
||||
return (
|
||||
<div className="colormap-editor">
|
||||
<div className="colormap-preview" style={{ backgroundImage: colorMapGradient(stops) }} />
|
||||
<div className="colormap-stop-list">
|
||||
{stops.map((stop, index) => {
|
||||
const isEndpoint = index === 0 || index === stops.length - 1;
|
||||
return (
|
||||
<div className="colormap-stop-row" key={`${index}-${stop.position}-${stop.color}`}>
|
||||
<span className="colormap-stop-label">{isEndpoint ? (index === 0 ? 'min' : 'max') : `stop ${index}`}</span>
|
||||
<input
|
||||
className="nodrag colormap-stop-color"
|
||||
type="color"
|
||||
value={normalizeHexColor(stop.color, '#000000')}
|
||||
onChange={(e) => updateStop(index, { color: e.target.value })}
|
||||
/>
|
||||
{isEndpoint ? (
|
||||
<span className="colormap-stop-boundary">{index === 0 ? '0%' : '100%'}</span>
|
||||
) : (
|
||||
<input
|
||||
className="nodrag colormap-stop-position"
|
||||
type="number"
|
||||
min="0.001"
|
||||
max="0.999"
|
||||
step="0.01"
|
||||
value={Number(stop.position.toFixed(4))}
|
||||
onChange={(e) => updateStop(index, { position: Number(e.target.value) })}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="nodrag colormap-stop-action"
|
||||
type="button"
|
||||
disabled={isEndpoint}
|
||||
onClick={() => removeStop(index)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button className="nodrag widget-button colormap-add-stop" type="button" onClick={addStop}>
|
||||
Add Stop
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeTable({ rows }) {
|
||||
const columns = getTableColumns(rows);
|
||||
if (columns.length === 0) return null;
|
||||
@@ -440,6 +727,9 @@ function CustomNode({ id, data }) {
|
||||
const def = data.definition;
|
||||
const scalarDisplay = formatScalarDisplay(data.scalarValue);
|
||||
const processingTimeText = formatProcessingTime(data.processingTimeMs);
|
||||
const connectedPathInfo = useStore(
|
||||
useCallback((s) => getConnectedOutputInfo(s, id, 'path'), [id]),
|
||||
);
|
||||
|
||||
// Parse inputs into data handles and widgets
|
||||
const required = def.input.required || {};
|
||||
@@ -447,13 +737,15 @@ function CustomNode({ id, data }) {
|
||||
|
||||
const dataInputs = [];
|
||||
const widgets = [];
|
||||
const visibleInputNames = new Set();
|
||||
|
||||
const hiddenWidgets = new Set();
|
||||
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const [type, opts] = Array.isArray(spec) ? spec : [spec, {}];
|
||||
if (DATA_TYPES.has(type)) {
|
||||
dataInputs.push({ name, type });
|
||||
dataInputs.push({ name, type, label: opts?.label || name });
|
||||
visibleInputNames.add(name);
|
||||
} else if (opts?.hidden) {
|
||||
hiddenWidgets.add(name);
|
||||
} else {
|
||||
@@ -467,7 +759,6 @@ function CustomNode({ id, data }) {
|
||||
const connectedInputs = useStore(
|
||||
useCallback(
|
||||
(s) => {
|
||||
if (!isProgressive) return null;
|
||||
const set = new Set();
|
||||
for (const e of s.edges) {
|
||||
if (e.target === id) {
|
||||
@@ -477,7 +768,7 @@ function CustomNode({ id, data }) {
|
||||
}
|
||||
return set;
|
||||
},
|
||||
[id, isProgressive],
|
||||
[id],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -503,7 +794,8 @@ function CustomNode({ id, data }) {
|
||||
if (match) {
|
||||
const idx = parseInt(match[1], 10);
|
||||
if (idx === 0 || (connectedInputs && connectedInputs.has(`field_${idx - 1}`))) {
|
||||
dataInputs.push({ name, type });
|
||||
dataInputs.push({ name, type, label: opts?.label || name });
|
||||
visibleInputNames.add(name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -511,12 +803,42 @@ function CustomNode({ id, data }) {
|
||||
if (opts?.hidden) {
|
||||
hiddenWidgets.add(name);
|
||||
} else if (DATA_TYPES.has(type)) {
|
||||
dataInputs.push({ name, type });
|
||||
dataInputs.push({ name, type, label: opts?.label || name });
|
||||
visibleInputNames.add(name);
|
||||
} else {
|
||||
widgets.push({ name, type, opts: opts || {}, socketType: SOCKET_WIDGET_TYPES.has(type) ? type : null });
|
||||
}
|
||||
}
|
||||
|
||||
const visibleWidgets = widgets.filter((w) => (
|
||||
widgetVisibleForSourceType(w, connectedSourceTypes?.[getWidgetSourceInputName(w.opts)])
|
||||
&& widgetVisibleForWidgetValues(w, data.widgetValues)
|
||||
&& widgetVisibleForInputVisibility(w, visibleInputNames)
|
||||
&& !widgetHiddenByConnectedInput(w, connectedInputs)
|
||||
));
|
||||
|
||||
const combinedTopInputNames = new Set(
|
||||
visibleWidgets
|
||||
.map((widget) => widget?.opts?.top_socket_input)
|
||||
.filter((name) => typeof name === 'string' && name.length > 0),
|
||||
);
|
||||
const renderedDataInputs = dataInputs.filter((input) => !combinedTopInputNames.has(input.name));
|
||||
const dataInputByName = new Map(dataInputs.map((input) => [input.name, input]));
|
||||
|
||||
const inlineWidgetsByInput = new Map();
|
||||
const topWidgets = [];
|
||||
const standaloneWidgets = [];
|
||||
for (const widget of visibleWidgets) {
|
||||
const inlineInputName = getWidgetInlineInputName(widget);
|
||||
if (inlineInputName) {
|
||||
inlineWidgetsByInput.set(inlineInputName, widget);
|
||||
} else if (widget.opts?.placement === 'top') {
|
||||
topWidgets.push(widget);
|
||||
} else {
|
||||
standaloneWidgets.push(widget);
|
||||
}
|
||||
}
|
||||
|
||||
const outputs = def.output.map((type, i) => ({
|
||||
name: def.output_name[i] || type,
|
||||
type,
|
||||
@@ -524,30 +846,85 @@ function CustomNode({ id, data }) {
|
||||
}));
|
||||
|
||||
const catColor = CAT_COLORS[def.category] || '#333';
|
||||
const maxIORows = Math.max(dataInputs.length, outputs.length);
|
||||
const maxIORows = Math.max(renderedDataInputs.length, outputs.length);
|
||||
const hasInteractiveLineOverlay = data.overlay?.kind === 'line_plot' && hiddenWidgets.has('x1');
|
||||
const hasInteractiveOverlay = !!data.overlay && (hiddenWidgets.has('x1') || data.overlay.kind === 'mask_paint');
|
||||
const hidePreviewForInteractiveMask = data.overlay?.kind === 'mask_paint';
|
||||
const hasInteractiveOverlay = !!data.overlay && (
|
||||
hiddenWidgets.has('x1')
|
||||
|| data.overlay.kind === 'mask_paint'
|
||||
|| data.overlay.kind === 'markup'
|
||||
);
|
||||
const hidePreviewForInteractiveMask = data.overlay?.kind === 'mask_paint' || data.overlay?.kind === 'markup';
|
||||
const overlayTitle = data.overlay?.section_title
|
||||
|| (data.overlay?.kind === 'mask_paint'
|
||||
? 'Mask'
|
||||
: data.overlay?.kind === 'markup'
|
||||
? 'Markup'
|
||||
: data.overlay?.kind === 'crop_box'
|
||||
? 'Crop'
|
||||
: data.overlay?.kind === 'line_plot'
|
||||
? 'Line Plot'
|
||||
: 'Cross Section');
|
||||
: 'Cross Section');
|
||||
const headerMeta = (() => {
|
||||
if (data.className === 'Folder') {
|
||||
return getBasename(data.widgetValues?.folder);
|
||||
}
|
||||
if (data.className === 'LoadFile') {
|
||||
return getBasename(connectedPathInfo?.path || data.widgetValues?.filename);
|
||||
}
|
||||
if (data.className === 'LoadDemo') {
|
||||
return getBasename(data.widgetValues?.name);
|
||||
}
|
||||
return '';
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="custom-node">
|
||||
{/* Title */}
|
||||
<div className="node-title drag-handle" style={{ background: catColor }}>
|
||||
{data.label}
|
||||
<span className="node-title-main">{data.label}</span>
|
||||
{headerMeta && <span className="node-title-meta" title={headerMeta}>{headerMeta}</span>}
|
||||
</div>
|
||||
|
||||
<div className="node-body">
|
||||
{topWidgets.length > 0 && (
|
||||
<div className="top-widget-section">
|
||||
{topWidgets.map((w) => (
|
||||
<div className={`widget-row${w.socketType ? ' widget-row-socket' : ''}`} key={w.name}>
|
||||
{(w.socketType || w.opts?.top_socket_input) && (() => {
|
||||
const socketInput = w.opts?.top_socket_input ? dataInputByName.get(w.opts.top_socket_input) : null;
|
||||
const socketType = w.socketType || socketInput?.type;
|
||||
const socketName = w.socketType ? w.name : socketInput?.name;
|
||||
if (!socketType || !socketName) return null;
|
||||
return (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={`input::${socketName}::${socketType}`}
|
||||
className="typed-handle"
|
||||
style={{ background: TYPE_COLORS[socketType] || '#999' }}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<WidgetControl
|
||||
widget={w}
|
||||
nodeId={id}
|
||||
value={data.widgetValues[w.name]}
|
||||
widgetValues={data.widgetValues}
|
||||
onChange={ctx.onWidgetChange}
|
||||
openFileBrowser={ctx.openFileBrowser}
|
||||
connected={!!(
|
||||
(w.socketType && connectedInputs?.has(w.name))
|
||||
|| (w.opts?.top_socket_input && connectedInputs?.has(w.opts.top_socket_input))
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* I/O rows — pair inputs[i] with outputs[i] */}
|
||||
{Array.from({ length: maxIORows }, (_, i) => {
|
||||
const inp = dataInputs[i];
|
||||
const inp = renderedDataInputs[i];
|
||||
const out = outputs[i];
|
||||
return (
|
||||
<div className="io-row" key={`io-${i}`}>
|
||||
@@ -561,7 +938,20 @@ function CustomNode({ id, data }) {
|
||||
className="typed-handle"
|
||||
style={{ background: TYPE_COLORS[inp.type] || '#999' }}
|
||||
/>
|
||||
<span className="io-label">{inp.name}</span>
|
||||
<span className="io-label">{inp.label || inp.name}</span>
|
||||
{inlineWidgetsByInput.has(inp.name) && (
|
||||
<div className="io-inline-widget">
|
||||
<WidgetControl
|
||||
widget={inlineWidgetsByInput.get(inp.name)}
|
||||
nodeId={id}
|
||||
value={data.widgetValues[inlineWidgetsByInput.get(inp.name).name]}
|
||||
widgetValues={data.widgetValues}
|
||||
onChange={ctx.onWidgetChange}
|
||||
openFileBrowser={ctx.openFileBrowser}
|
||||
hideLabel={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -601,7 +991,7 @@ function CustomNode({ id, data }) {
|
||||
)}
|
||||
|
||||
{/* Widget rows */}
|
||||
{widgets.filter((w) => widgetVisibleForSourceType(w, connectedSourceTypes?.[getWidgetSourceInputName(w.opts)])).map((w) => (
|
||||
{standaloneWidgets.map((w) => (
|
||||
<div className={`widget-row${w.socketType ? ' widget-row-socket' : ''}`} key={w.name}>
|
||||
{w.socketType && (
|
||||
<Handle
|
||||
@@ -619,6 +1009,7 @@ function CustomNode({ id, data }) {
|
||||
widgetValues={data.widgetValues}
|
||||
onChange={ctx.onWidgetChange}
|
||||
openFileBrowser={ctx.openFileBrowser}
|
||||
connected={!!(w.socketType && connectedInputs?.has(w.name))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -654,6 +1045,7 @@ function CustomNode({ id, data }) {
|
||||
resetKey={typeof data.previewImage === 'string' ? data.previewImage : JSON.stringify({
|
||||
kind: data.previewImage.kind,
|
||||
len: data.previewImage.line?.length,
|
||||
layers: data.previewImage.layers?.length,
|
||||
})}
|
||||
fallbackImage={typeof data.previewImage === 'object' ? data.previewImage.fallback_image : null}
|
||||
>
|
||||
@@ -661,6 +1053,8 @@ function CustomNode({ id, data }) {
|
||||
<div className="node-preview">
|
||||
<img src={data.previewImage} alt="preview" draggable={false} />
|
||||
</div>
|
||||
) : data.previewImage.kind === 'layer_gallery' ? (
|
||||
<LayerGalleryPreview overlay={data.previewImage} />
|
||||
) : data.previewImage.kind === 'line_plot' ? (
|
||||
<LinePlotOverlay overlay={data.previewImage} interactive={false} />
|
||||
) : null}
|
||||
@@ -704,6 +1098,16 @@ function CustomNode({ id, data }) {
|
||||
nodeId={id}
|
||||
onWidgetChange={ctx.onWidgetChange}
|
||||
/>
|
||||
) : data.overlay.kind === 'markup' ? (
|
||||
<MarkupOverlay
|
||||
image={data.overlay.image}
|
||||
shape={data.widgetValues.shape ?? data.overlay.shape}
|
||||
strokeColor={data.widgetValues.stroke_color ?? data.overlay.stroke_color}
|
||||
strokeWidth={data.widgetValues.stroke_width ?? data.overlay.stroke_width}
|
||||
markupShapes={data.widgetValues.markup_shapes}
|
||||
nodeId={id}
|
||||
onWidgetChange={ctx.onWidgetChange}
|
||||
/>
|
||||
) : (
|
||||
<CrossSectionOverlay
|
||||
image={data.overlay.image}
|
||||
@@ -739,9 +1143,11 @@ function CustomNode({ id, data }) {
|
||||
|
||||
// ── Widget renderer ───────────────────────────────────────────────────
|
||||
|
||||
function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFileBrowser }) {
|
||||
function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFileBrowser, connected = false, hideLabel = false }) {
|
||||
const { name, type, opts } = widget;
|
||||
const label = opts?.label || name;
|
||||
const val = value ?? opts?.default ?? '';
|
||||
const placeholder = opts?.placeholder || '';
|
||||
const dynamicSourceType = useStore(
|
||||
useCallback(
|
||||
(s) => {
|
||||
@@ -818,11 +1224,34 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
|
||||
onChange(nodeId, name, dynamicTypeChoices[0]);
|
||||
}, [dynamicTypeChoices, name, nodeId, onChange, val]);
|
||||
|
||||
if (connected) {
|
||||
return (
|
||||
<>
|
||||
{!hideLabel && <label>{label}</label>}
|
||||
<div className="widget-linked-state">Connected</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (opts?.colormap_stops) {
|
||||
return (
|
||||
<>
|
||||
{!hideLabel && <label>{label}</label>}
|
||||
<ColorMapStopsEditor
|
||||
nodeId={nodeId}
|
||||
name={name}
|
||||
value={val}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Combo / enum — type itself is the array of options
|
||||
if (Array.isArray(type)) {
|
||||
return (
|
||||
<>
|
||||
<label>{name}</label>
|
||||
{!hideLabel && <label>{label}</label>}
|
||||
<select
|
||||
className="nodrag"
|
||||
value={val || type[0]}
|
||||
@@ -840,7 +1269,7 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
|
||||
const selected = dynamicTypeChoices.includes(String(val)) ? String(val) : dynamicTypeChoices[0];
|
||||
return (
|
||||
<>
|
||||
<label>{name}</label>
|
||||
{!hideLabel && <label>{label}</label>}
|
||||
<select
|
||||
className="nodrag"
|
||||
value={selected}
|
||||
@@ -858,7 +1287,7 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
|
||||
const selected = dynamicTableColumns.includes(String(val)) ? String(val) : dynamicTableColumns[0];
|
||||
return (
|
||||
<>
|
||||
<label>{name}</label>
|
||||
{!hideLabel && <label>{label}</label>}
|
||||
<select
|
||||
className="nodrag"
|
||||
value={selected}
|
||||
@@ -876,7 +1305,7 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
|
||||
const selected = dynamicMeasurementChoices.includes(String(val)) ? String(val) : dynamicMeasurementChoices[0];
|
||||
return (
|
||||
<>
|
||||
<label>{name}</label>
|
||||
{!hideLabel && <label>{label}</label>}
|
||||
<select
|
||||
className="nodrag"
|
||||
value={selected}
|
||||
@@ -890,21 +1319,25 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'FILE_PICKER') {
|
||||
if (type === 'FILE_PICKER' || type === 'FOLDER_PICKER') {
|
||||
const isFolderPicker = type === 'FOLDER_PICKER';
|
||||
return (
|
||||
<>
|
||||
<label>{name}</label>
|
||||
{!hideLabel && <label>{label}</label>}
|
||||
<div className="file-picker-row">
|
||||
<input
|
||||
className="nodrag"
|
||||
type="text"
|
||||
value={val}
|
||||
onChange={(e) => onChange(nodeId, name, e.target.value)}
|
||||
placeholder="Select file…"
|
||||
placeholder={placeholder || (isFolderPicker ? 'Select folder…' : 'Select file…')}
|
||||
/>
|
||||
<button
|
||||
className="nodrag browse-btn"
|
||||
onClick={() => openFileBrowser((path) => onChange(nodeId, name, path))}
|
||||
onClick={() => openFileBrowser(
|
||||
(path) => onChange(nodeId, name, path),
|
||||
{ selectionMode: isFolderPicker ? 'folder' : 'file' },
|
||||
)}
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
@@ -913,6 +1346,23 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'STRING' && opts?.color_picker) {
|
||||
const normalized = typeof val === 'string' && /^#[0-9a-fA-F]{6}$/.test(val)
|
||||
? val
|
||||
: '#ffd54f';
|
||||
return (
|
||||
<>
|
||||
{!hideLabel && <label>{label}</label>}
|
||||
<input
|
||||
className="nodrag widget-color-input"
|
||||
type="color"
|
||||
value={normalized}
|
||||
onChange={(e) => onChange(nodeId, name, e.target.value)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'BUTTON') {
|
||||
const updates = opts?.set_widgets && typeof opts.set_widgets === 'object'
|
||||
? Object.entries(opts.set_widgets)
|
||||
@@ -950,7 +1400,7 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
|
||||
|
||||
return (
|
||||
<>
|
||||
<label>{name}</label>
|
||||
{!hideLabel && <label>{label}</label>}
|
||||
<div className="slider-control">
|
||||
<input
|
||||
className="nodrag slider-input"
|
||||
@@ -969,7 +1419,7 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
|
||||
|
||||
return (
|
||||
<>
|
||||
<label>{name}</label>
|
||||
{!hideLabel && <label>{label}</label>}
|
||||
<DraggableNumber
|
||||
value={val || 0}
|
||||
step={opts?.step ?? 0.01}
|
||||
@@ -985,7 +1435,7 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
|
||||
if (type === 'INT') {
|
||||
return (
|
||||
<>
|
||||
<label>{name}</label>
|
||||
{!hideLabel && <label>{label}</label>}
|
||||
<DraggableNumber
|
||||
value={val || 0}
|
||||
step={opts?.step ?? 1}
|
||||
@@ -1001,7 +1451,7 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
|
||||
if (type === 'BOOLEAN') {
|
||||
return (
|
||||
<>
|
||||
<label>{name}</label>
|
||||
{!hideLabel && <label>{label}</label>}
|
||||
<input
|
||||
className="nodrag"
|
||||
type="checkbox"
|
||||
@@ -1015,11 +1465,12 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
|
||||
// STRING and anything else
|
||||
return (
|
||||
<>
|
||||
<label>{name}</label>
|
||||
{!hideLabel && <label>{label}</label>}
|
||||
<input
|
||||
className="nodrag"
|
||||
type="text"
|
||||
value={val}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(nodeId, name, e.target.value)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -5,10 +5,10 @@ import * as api from './api';
|
||||
* Server-side file browser modal.
|
||||
*
|
||||
* Props:
|
||||
* onSelect(absolutePath) — called when user picks a file
|
||||
* onSelect(absolutePath) — called when user picks a file or folder
|
||||
* onClose() — called when user dismisses the dialog
|
||||
*/
|
||||
export default function FileBrowser({ onSelect, onClose }) {
|
||||
export default function FileBrowser({ onSelect, onClose, selectionMode = 'file' }) {
|
||||
const [path, setPath] = useState('');
|
||||
const [parent, setParent] = useState(null);
|
||||
const [dirs, setDirs] = useState([]);
|
||||
@@ -43,6 +43,11 @@ export default function FileBrowser({ onSelect, onClose }) {
|
||||
{/* Header */}
|
||||
<div className="fb-header">
|
||||
<span className="fb-path">{path}</span>
|
||||
{selectionMode === 'folder' && (
|
||||
<button className="fb-select-btn" onClick={() => { onSelect(path); onClose(); }}>
|
||||
Select Folder
|
||||
</button>
|
||||
)}
|
||||
<button className="fb-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
@@ -75,8 +80,12 @@ export default function FileBrowser({ onSelect, onClose }) {
|
||||
{files.map((f) => (
|
||||
<div
|
||||
key={f}
|
||||
className="fb-entry fb-file"
|
||||
onClick={() => { onSelect(path + '/' + f); onClose(); }}
|
||||
className={`fb-entry fb-file${selectionMode === 'folder' ? ' fb-file-disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (selectionMode === 'folder') return;
|
||||
onSelect(path + '/' + f);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{f}
|
||||
</div>
|
||||
|
||||
285
frontend/src/MarkupOverlay.jsx
Normal file
285
frontend/src/MarkupOverlay.jsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
function clampFraction(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return 0;
|
||||
return Math.max(0, Math.min(1, numeric));
|
||||
}
|
||||
|
||||
function sanitizeColor(color, fallback = '#ffd54f') {
|
||||
if (typeof color !== 'string') return fallback;
|
||||
const value = color.trim();
|
||||
return /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : fallback;
|
||||
}
|
||||
|
||||
function sanitizeShape(shape, fallbackShape, fallbackColor, fallbackWidth) {
|
||||
if (!shape || typeof shape !== 'object') return null;
|
||||
const kind = ['line', 'rectangle', 'circle', 'arrow'].includes(shape.kind) ? shape.kind : fallbackShape;
|
||||
const x1 = clampFraction(shape.x1);
|
||||
const y1 = clampFraction(shape.y1);
|
||||
const x2 = clampFraction(shape.x2);
|
||||
const y2 = clampFraction(shape.y2);
|
||||
const width = Math.max(1, Math.min(64, Math.round(Number(shape.width) || fallbackWidth || 1)));
|
||||
return {
|
||||
kind,
|
||||
x1: Number(x1.toFixed(4)),
|
||||
y1: Number(y1.toFixed(4)),
|
||||
x2: Number(x2.toFixed(4)),
|
||||
y2: Number(y2.toFixed(4)),
|
||||
width,
|
||||
color: sanitizeColor(shape.color, fallbackColor),
|
||||
};
|
||||
}
|
||||
|
||||
function parseMarkupShapes(markupShapes, fallbackShape, fallbackColor, fallbackWidth) {
|
||||
if (Array.isArray(markupShapes)) {
|
||||
return markupShapes
|
||||
.map((shape) => sanitizeShape(shape, fallbackShape, fallbackColor, fallbackWidth))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (typeof markupShapes !== 'string' || !markupShapes.trim()) return [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(markupShapes);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.map((shape) => sanitizeShape(shape, fallbackShape, fallbackColor, fallbackWidth))
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function arrowPoints(shape, imageWidth, imageHeight) {
|
||||
const x1 = shape.x1 * imageWidth;
|
||||
const y1 = shape.y1 * imageHeight;
|
||||
const x2 = shape.x2 * imageWidth;
|
||||
const y2 = shape.y2 * imageHeight;
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / length;
|
||||
const uy = dy / length;
|
||||
const strokeWidth = Math.max(1, shape.width);
|
||||
const headLength = Math.max(10, strokeWidth * 4);
|
||||
const headWidth = Math.max(8, strokeWidth * 3);
|
||||
const overlap = Math.max(1, strokeWidth * 0.75);
|
||||
const shaftX = x2 - ux * Math.max(0, headLength - overlap);
|
||||
const shaftY = y2 - uy * Math.max(0, headLength - overlap);
|
||||
const headBaseX = x2 - ux * headLength;
|
||||
const headBaseY = y2 - uy * headLength;
|
||||
const px = -uy;
|
||||
const py = ux;
|
||||
const leftX = headBaseX + px * headWidth * 0.5;
|
||||
const leftY = headBaseY + py * headWidth * 0.5;
|
||||
const rightX = headBaseX - px * headWidth * 0.5;
|
||||
const rightY = headBaseY - py * headWidth * 0.5;
|
||||
return {
|
||||
line: `${x1},${y1} ${shaftX},${shaftY}`,
|
||||
head: `${x2},${y2} ${leftX},${leftY} ${rightX},${rightY}`,
|
||||
};
|
||||
}
|
||||
|
||||
function ShapeElement({ shape, imageWidth, imageHeight }) {
|
||||
const x1 = shape.x1 * imageWidth;
|
||||
const y1 = shape.y1 * imageHeight;
|
||||
const x2 = shape.x2 * imageWidth;
|
||||
const y2 = shape.y2 * imageHeight;
|
||||
const left = Math.min(x1, x2);
|
||||
const top = Math.min(y1, y2);
|
||||
const width = Math.abs(x2 - x1);
|
||||
const height = Math.abs(y2 - y1);
|
||||
const strokeWidth = Math.max(1, shape.width);
|
||||
const common = {
|
||||
fill: 'none',
|
||||
stroke: shape.color,
|
||||
strokeWidth,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
};
|
||||
|
||||
if (shape.kind === 'line') {
|
||||
return <line x1={x1} y1={y1} x2={x2} y2={y2} {...common} />;
|
||||
}
|
||||
|
||||
if (shape.kind === 'rectangle') {
|
||||
return <rect x={left} y={top} width={width} height={height} {...common} />;
|
||||
}
|
||||
|
||||
if (shape.kind === 'circle') {
|
||||
return (
|
||||
<ellipse
|
||||
cx={left + width / 2}
|
||||
cy={top + height / 2}
|
||||
rx={width / 2}
|
||||
ry={height / 2}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const arrow = arrowPoints(shape, imageWidth, imageHeight);
|
||||
return (
|
||||
<>
|
||||
<polyline points={arrow.line} {...common} />
|
||||
<polygon
|
||||
points={arrow.head}
|
||||
fill={shape.color}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MarkupOverlay({
|
||||
image,
|
||||
shape,
|
||||
strokeColor,
|
||||
strokeWidth,
|
||||
markupShapes,
|
||||
nodeId,
|
||||
onWidgetChange,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const imageRef = useRef(null);
|
||||
const shapesRef = useRef([]);
|
||||
const [draftShape, setDraftShape] = useState(null);
|
||||
const [drawing, setDrawing] = useState(false);
|
||||
const [imageSize, setImageSize] = useState({ width: 1, height: 1 });
|
||||
|
||||
const normalizedShape = useMemo(
|
||||
() => (['line', 'rectangle', 'circle', 'arrow'].includes(shape) ? shape : 'line'),
|
||||
[shape],
|
||||
);
|
||||
const normalizedColor = useMemo(() => sanitizeColor(strokeColor, '#ffd54f'), [strokeColor]);
|
||||
const normalizedWidth = useMemo(
|
||||
() => Math.max(1, Math.min(64, Math.round(Number(strokeWidth) || 3))),
|
||||
[strokeWidth],
|
||||
);
|
||||
|
||||
const committedShapes = useMemo(
|
||||
() => parseMarkupShapes(markupShapes, normalizedShape, normalizedColor, normalizedWidth),
|
||||
[markupShapes, normalizedShape, normalizedColor, normalizedWidth],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
shapesRef.current = committedShapes;
|
||||
}, [committedShapes]);
|
||||
|
||||
useEffect(() => {
|
||||
const img = imageRef.current;
|
||||
if (!img) return;
|
||||
const updateImageSize = () => {
|
||||
const width = Math.max(1, img.naturalWidth || img.width || 1);
|
||||
const height = Math.max(1, img.naturalHeight || img.height || 1);
|
||||
setImageSize({ width, height });
|
||||
};
|
||||
|
||||
updateImageSize();
|
||||
if (!img.complete) {
|
||||
img.addEventListener('load', updateImageSize);
|
||||
return () => img.removeEventListener('load', updateImageSize);
|
||||
}
|
||||
return undefined;
|
||||
}, [image]);
|
||||
|
||||
const getPoint = useCallback((event) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return null;
|
||||
return {
|
||||
x: Number(clampFraction((event.clientX - rect.left) / rect.width).toFixed(4)),
|
||||
y: Number(clampFraction((event.clientY - rect.top) / rect.height).toFixed(4)),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const commitShapes = useCallback((nextShapes) => {
|
||||
if (!nodeId || !onWidgetChange) return;
|
||||
onWidgetChange(nodeId, 'markup_shapes', JSON.stringify(nextShapes));
|
||||
}, [nodeId, onWidgetChange]);
|
||||
|
||||
const handlePointerDown = useCallback((event) => {
|
||||
if (!onWidgetChange || event.target.closest('button')) return;
|
||||
const point = getPoint(event);
|
||||
if (!point) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setDrawing(true);
|
||||
setDraftShape({
|
||||
kind: normalizedShape,
|
||||
color: normalizedColor,
|
||||
width: normalizedWidth,
|
||||
x1: point.x,
|
||||
y1: point.y,
|
||||
x2: point.x,
|
||||
y2: point.y,
|
||||
});
|
||||
}, [getPoint, normalizedColor, normalizedShape, normalizedWidth, onWidgetChange]);
|
||||
|
||||
const handlePointerMove = useCallback((event) => {
|
||||
if (!drawing) return;
|
||||
const point = getPoint(event);
|
||||
if (!point) return;
|
||||
setDraftShape((current) => (current ? { ...current, x2: point.x, y2: point.y } : current));
|
||||
}, [drawing, getPoint]);
|
||||
|
||||
const finishDrawing = useCallback(() => {
|
||||
if (!draftShape) {
|
||||
setDrawing(false);
|
||||
return;
|
||||
}
|
||||
const nextShape = sanitizeShape(draftShape, normalizedShape, normalizedColor, normalizedWidth);
|
||||
setDraftShape(null);
|
||||
setDrawing(false);
|
||||
if (!nextShape) return;
|
||||
commitShapes([...shapesRef.current, nextShape]);
|
||||
}, [commitShapes, draftShape, normalizedColor, normalizedShape, normalizedWidth]);
|
||||
|
||||
const undoLast = useCallback(() => {
|
||||
if (shapesRef.current.length === 0) return;
|
||||
commitShapes(shapesRef.current.slice(0, -1));
|
||||
}, [commitShapes]);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
commitShapes([]);
|
||||
}, [commitShapes]);
|
||||
|
||||
const renderedShapes = draftShape ? [...committedShapes, draftShape] : committedShapes;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`nodrag nowheel markup-overlay${drawing ? ' markup-overlay-drawing' : ''}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={finishDrawing}
|
||||
onPointerCancel={finishDrawing}
|
||||
onLostPointerCapture={finishDrawing}
|
||||
>
|
||||
<img ref={imageRef} src={image} alt="markup source" draggable={false} className="markup-image" />
|
||||
<svg
|
||||
className="markup-svg"
|
||||
viewBox={`0 0 ${imageSize.width} ${imageSize.height}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{renderedShapes.map((item, index) => (
|
||||
<ShapeElement
|
||||
key={`${item.kind}-${index}`}
|
||||
shape={item}
|
||||
imageWidth={imageSize.width}
|
||||
imageHeight={imageSize.height}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
<div className="markup-toolbar">
|
||||
<button className="markup-tool-btn" type="button" onClick={undoLast} disabled={committedShapes.length === 0}>
|
||||
Undo
|
||||
</button>
|
||||
<button className="markup-tool-btn" type="button" onClick={clearAll} disabled={committedShapes.length === 0}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,12 @@ export async function getChannels(filepath) {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function getFolderFiles(folderpath) {
|
||||
const r = await fetch(`/folder-files?folder=${encodeURIComponent(folderpath)}`);
|
||||
if (!r.ok) return [];
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function runPrompt(prompt) {
|
||||
const r = await fetch('/prompt', {
|
||||
method: 'POST',
|
||||
|
||||
56
frontend/src/defaultWorkflow.js
Normal file
56
frontend/src/defaultWorkflow.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { extractWorkflow } from './pngMetadata.js';
|
||||
|
||||
const DEFAULT_WORKFLOW_CANDIDATES = [
|
||||
{ path: '/default-workflow.json', type: 'json' },
|
||||
{ path: '/default-workflow.png', type: 'png' },
|
||||
];
|
||||
|
||||
async function loadCandidate(candidate, fetchImpl, extractWorkflowFn) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(candidate.path, { cache: 'no-store' });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = response.headers?.get?.('content-type') || '';
|
||||
const isHtmlFallback = typeof contentType === 'string' && contentType.toLowerCase().includes('text/html');
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404 || response.status === 0) return null;
|
||||
throw new Error(`Failed to load ${candidate.path} (${response.status})`);
|
||||
}
|
||||
|
||||
if (candidate.type === 'json') {
|
||||
if (isHtmlFallback) return null;
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
throw new Error(`${candidate.path} is not valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
if (isHtmlFallback) return null;
|
||||
const workflow = await extractWorkflowFn(await response.blob());
|
||||
if (!workflow) {
|
||||
throw new Error(`${candidate.path} does not contain embedded workflow metadata`);
|
||||
}
|
||||
return workflow;
|
||||
}
|
||||
|
||||
export async function loadDefaultWorkflowAsset({
|
||||
fetchImpl = fetch,
|
||||
extractWorkflowFn = extractWorkflow,
|
||||
} = {}) {
|
||||
for (const candidate of DEFAULT_WORKFLOW_CANDIDATES) {
|
||||
const workflow = await loadCandidate(candidate, fetchImpl, extractWorkflowFn);
|
||||
if (workflow) {
|
||||
return {
|
||||
source: candidate.path,
|
||||
format: candidate.type,
|
||||
workflow,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
125
frontend/src/executionGraph.js
Normal file
125
frontend/src/executionGraph.js
Normal file
@@ -0,0 +1,125 @@
|
||||
const DATA_TYPES = new Set([
|
||||
'DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'ANY_TABLE',
|
||||
'COORD', 'STATS_SOURCE', 'VALUE_SOURCE', 'COLORMAP', 'SAVE_LAYER', 'FONT', 'FILE_PATH', 'DIRECTORY',
|
||||
]);
|
||||
|
||||
function getInputName(handleId) {
|
||||
return handleId.split('::')[1];
|
||||
}
|
||||
|
||||
function getOutputSlot(handleId) {
|
||||
return parseInt(handleId.split('::')[1], 10);
|
||||
}
|
||||
|
||||
export function getConnectedNodeIds(edges) {
|
||||
const connectedNodeIds = new Set();
|
||||
for (const edge of edges) {
|
||||
connectedNodeIds.add(edge.source);
|
||||
connectedNodeIds.add(edge.target);
|
||||
}
|
||||
return connectedNodeIds;
|
||||
}
|
||||
|
||||
function isPreviewLoadNode(node) {
|
||||
return ['LoadFile', 'LoadDemo'].includes(node?.data?.className);
|
||||
}
|
||||
|
||||
function hasPreviewLoadSelection(node) {
|
||||
if (node?.data?.className === 'LoadFile') {
|
||||
return !!String(node.data?.widgetValues?.filename || '').trim();
|
||||
}
|
||||
if (node?.data?.className === 'LoadDemo') {
|
||||
return !!String(node.data?.widgetValues?.name || '').trim();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getRunnableNodeIds(nodes, edges) {
|
||||
const connectedNodeIds = getConnectedNodeIds(edges);
|
||||
|
||||
const runnableNodeIds = new Set(connectedNodeIds);
|
||||
for (const node of nodes) {
|
||||
if (connectedNodeIds.has(node.id)) continue;
|
||||
if (isPreviewLoadNode(node) && hasPreviewLoadSelection(node)) {
|
||||
runnableNodeIds.add(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
return runnableNodeIds;
|
||||
}
|
||||
|
||||
export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = false } = {}) {
|
||||
const runnableNodeIds = getRunnableNodeIds(nodes, edges);
|
||||
const prompt = {};
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!runnableNodeIds.has(node.id)) continue;
|
||||
|
||||
const { className, definition, widgetValues } = node.data;
|
||||
if (!definition) continue;
|
||||
if (excludeManualTrigger && definition.manual_trigger) continue;
|
||||
|
||||
const inputs = {};
|
||||
|
||||
const allWidgets = {
|
||||
...(definition.input.required || {}),
|
||||
...(definition.input.optional || {}),
|
||||
};
|
||||
for (const [name, spec] of Object.entries(allWidgets)) {
|
||||
const [type] = Array.isArray(spec) ? spec : [spec];
|
||||
if (DATA_TYPES.has(type)) continue;
|
||||
if (type === 'BUTTON') continue;
|
||||
if (widgetValues[name] !== undefined) {
|
||||
inputs[name] = widgetValues[name];
|
||||
}
|
||||
}
|
||||
|
||||
const incoming = edges.filter((edge) => edge.target === node.id);
|
||||
for (const edge of incoming) {
|
||||
const inputName = getInputName(edge.targetHandle);
|
||||
const outputSlot = getOutputSlot(edge.sourceHandle);
|
||||
inputs[inputName] = [edge.source, outputSlot];
|
||||
}
|
||||
|
||||
prompt[node.id] = { class_type: className, inputs };
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
export function getAutoRunnableNodes(nodes, edges) {
|
||||
const runnableNodeIds = getRunnableNodeIds(nodes, edges);
|
||||
return nodes.filter((node) => runnableNodeIds.has(node.id));
|
||||
}
|
||||
|
||||
export function hasBlockingAutoRunInput(node, edges) {
|
||||
const def = node.data?.definition;
|
||||
if (!def || def.manual_trigger) return false;
|
||||
|
||||
const required = def.input.required || {};
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const [type, opts] = Array.isArray(spec) ? spec : [spec, {}];
|
||||
const hiddenByConnectedInput = (() => {
|
||||
const raw = opts?.hide_when_input_connected;
|
||||
if (!raw) return false;
|
||||
const inputs = Array.isArray(raw) ? raw : [raw];
|
||||
return inputs.some((inputName) => edges.some(
|
||||
(edge) => edge.target === node.id && getInputName(edge.targetHandle) === String(inputName)
|
||||
));
|
||||
})();
|
||||
|
||||
if (hiddenByConnectedInput) continue;
|
||||
|
||||
if (type === 'FILE_PICKER' || type === 'FOLDER_PICKER') {
|
||||
if (!node.data.widgetValues?.[name]) return true;
|
||||
continue;
|
||||
}
|
||||
if (!DATA_TYPES.has(type)) continue;
|
||||
const hasEdge = edges.some(
|
||||
(edge) => edge.target === node.id && getInputName(edge.targetHandle) === name
|
||||
);
|
||||
if (!hasEdge) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -141,6 +141,10 @@ html, body, #root {
|
||||
|
||||
.node-title {
|
||||
padding: 5px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
@@ -148,12 +152,36 @@ html, body, #root {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.node-title-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.node-title-meta {
|
||||
max-width: 48%;
|
||||
min-width: 0;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.28);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.node-body {
|
||||
padding: 4px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.top-widget-section {
|
||||
padding-bottom: 2px;
|
||||
border-bottom: 1px solid rgba(51, 65, 85, 0.35);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.node-warning {
|
||||
padding: 3px 10px;
|
||||
font-size: 10px;
|
||||
@@ -226,6 +254,11 @@ html, body, #root {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.io-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.io-label {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
@@ -280,8 +313,36 @@ html, body, #root {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.io-inline-widget {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.io-inline-widget .widget-row,
|
||||
.io-inline-widget label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.io-inline-widget input[type="text"],
|
||||
.io-inline-widget input[type="number"],
|
||||
.io-inline-widget input[type="color"],
|
||||
.io-inline-widget select {
|
||||
background: #0f172a;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 3px;
|
||||
padding: 2px 5px;
|
||||
font-size: 11px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.widget-row input[type="text"],
|
||||
.widget-row input[type="number"],
|
||||
.widget-row input[type="color"],
|
||||
.widget-row select {
|
||||
background: #0f172a;
|
||||
color: #e0e0e0;
|
||||
@@ -293,6 +354,11 @@ html, body, #root {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.widget-row input[type="color"] {
|
||||
padding: 2px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.widget-row input[type="checkbox"] {
|
||||
accent-color: #3a7abf;
|
||||
}
|
||||
@@ -314,6 +380,87 @@ html, body, #root {
|
||||
border-color: #3a7abf;
|
||||
}
|
||||
|
||||
.widget-linked-state {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 4px 8px;
|
||||
border: 1px dashed rgba(244, 114, 182, 0.45);
|
||||
border-radius: 4px;
|
||||
background: rgba(30, 41, 59, 0.55);
|
||||
color: #f9a8d4;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.colormap-editor {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.colormap-preview {
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #334155;
|
||||
background-color: #0f172a;
|
||||
}
|
||||
|
||||
.colormap-stop-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.colormap-stop-row {
|
||||
display: grid;
|
||||
grid-template-columns: 34px 34px minmax(0, 1fr) auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.colormap-stop-label,
|
||||
.colormap-stop-boundary {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.colormap-stop-color {
|
||||
width: 34px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 4px;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.colormap-stop-position {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.colormap-stop-action {
|
||||
background: #172554;
|
||||
color: #dbeafe;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.colormap-stop-action:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.colormap-add-stop {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.slider-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -438,6 +585,54 @@ html, body, #root {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.layer-gallery {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.layer-gallery-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) 28px;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.layer-gallery-btn {
|
||||
height: 26px;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.layer-gallery-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.layer-gallery-name {
|
||||
min-width: 0;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid rgba(51, 65, 85, 0.9);
|
||||
border-radius: 6px;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
color: #cbd5e1;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.layer-gallery-count {
|
||||
font-size: 10px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Cross-section overlay ────────────────────────────────────────── */
|
||||
.cs-overlay {
|
||||
position: relative;
|
||||
@@ -609,6 +804,60 @@ html, body, #root {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.markup-overlay {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.markup-overlay-drawing {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.markup-image {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.markup-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.markup-toolbar {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.markup-tool-btn {
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
background: rgba(15, 23, 42, 0.88);
|
||||
color: #e2e8f0;
|
||||
border-radius: 999px;
|
||||
padding: 4px 9px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.markup-tool-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── 3D surface view ──────────────────────────────────────────────── */
|
||||
.surface-view-container {
|
||||
width: 100%;
|
||||
@@ -830,7 +1079,7 @@ html, body, #root {
|
||||
.fb-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
}
|
||||
@@ -852,6 +1101,17 @@ html, body, #root {
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.fb-close:hover { color: #e94560; }
|
||||
.fb-select-btn {
|
||||
background: #0f3460;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fb-select-btn:hover { background: #1a4a8a; }
|
||||
.fb-list {
|
||||
overflow-y: auto;
|
||||
padding: 6px 0;
|
||||
@@ -868,6 +1128,13 @@ html, body, #root {
|
||||
.fb-entry:hover { background: #0f3460; }
|
||||
.fb-dir { color: #90caf9; }
|
||||
.fb-file { color: #e0e0e0; }
|
||||
.fb-file-disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.fb-file-disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.fb-loading {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
|
||||
@@ -34,6 +34,26 @@ function getInputType(definition, inputName) {
|
||||
return getSocketType(required[inputName] ?? optional[inputName]);
|
||||
}
|
||||
|
||||
function getInputEntries(definition) {
|
||||
return [
|
||||
...Object.entries(definition?.input?.required || {}),
|
||||
...Object.entries(definition?.input?.optional || {}),
|
||||
];
|
||||
}
|
||||
|
||||
function sanitizeWidgetValues(widgetValues, definition) {
|
||||
const nextValues = { ...(widgetValues || {}) };
|
||||
|
||||
getInputEntries(definition).forEach(([inputName, inputDef]) => {
|
||||
const type = getSocketType(inputDef);
|
||||
if (type === 'FILE_PICKER' || type === 'FOLDER_PICKER') {
|
||||
nextValues[inputName] = '';
|
||||
}
|
||||
});
|
||||
|
||||
return nextValues;
|
||||
}
|
||||
|
||||
function remapLegacyHandle(handleId, kind, nodeData) {
|
||||
if (typeof handleId !== 'string') return handleId;
|
||||
|
||||
@@ -63,22 +83,26 @@ export function hydrateWorkflowState(data, defs = {}) {
|
||||
const loadedNodes = Array.isArray(data?.nodes) ? data.nodes : [];
|
||||
const loadedEdges = Array.isArray(data?.edges) ? data.edges : [];
|
||||
|
||||
const nodes = loadedNodes.map((node) => ({
|
||||
...node,
|
||||
type: node.type || 'custom',
|
||||
dragHandle: node.dragHandle || '.drag-handle',
|
||||
data: {
|
||||
...node.data,
|
||||
label: node.data?.label || node.data?.className || 'Node',
|
||||
widgetValues: node.data?.widgetValues || {},
|
||||
definition: mergeDefinition(node.data, defs),
|
||||
previewImage: null,
|
||||
tableRows: null,
|
||||
meshData: null,
|
||||
overlay: null,
|
||||
scalarValue: null,
|
||||
},
|
||||
}));
|
||||
const nodes = loadedNodes.map((node) => {
|
||||
const definition = mergeDefinition(node.data, defs);
|
||||
|
||||
return {
|
||||
...node,
|
||||
type: node.type || 'custom',
|
||||
dragHandle: node.dragHandle || '.drag-handle',
|
||||
data: {
|
||||
...node.data,
|
||||
label: node.data?.label || node.data?.className || 'Node',
|
||||
widgetValues: sanitizeWidgetValues(node.data?.widgetValues, definition),
|
||||
definition,
|
||||
previewImage: null,
|
||||
tableRows: null,
|
||||
meshData: null,
|
||||
overlay: null,
|
||||
scalarValue: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const nodeById = new Map(nodes.map((node) => [String(node.id), node.data]));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user