import React, { useContext, useRef, useCallback, useState, memo, lazy, Suspense } from 'react'; import { Handle, Position, useStore } from '@xyflow/react'; import LinePlotOverlay from './LinePlotOverlay'; const SurfaceView = lazy(() => import('./SurfaceView')); const CrossSectionOverlay = lazy(() => import('./CrossSectionOverlay')); const CropBoxOverlay = lazy(() => import('./CropBoxOverlay')); // ── Constants ───────────────────────────────────────────────────────── const DATA_TYPES = new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'TABLE', 'COORD']); const SOCKET_WIDGET_TYPES = new Set(['FLOAT']); const TYPE_COLORS = { DATA_FIELD: '#3a7abf', IMAGE: '#4caf50', LINE: '#ff9800', TABLE: '#fdd835', COORD: '#e91e63', FLOAT: '#7dd3fc', }; const CAT_COLORS = { io: '#37474f', filters: '#1a237e', modify: '#0f766e', level: '#1b5e20', analysis: '#4a148c', grains: '#bf360c', display: '#212121', }; // ── Context (provided by App) ───────────────────────────────────────── export const NodeContext = React.createContext(null); class PreviewBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error) { console.error('[argonode] preview render failed', error); } componentDidUpdate(prevProps) { if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) { this.setState({ hasError: false }); } } render() { if (!this.state.hasError) { return this.props.children; } if (this.props.fallbackImage) { return (
preview fallback
); } return (
Preview unavailable.
); } } // ── Draggable number input ──────────────────────────────────────────── function DraggableNumber({ value, step, min, max, precision, onChange }) { const [editing, setEditing] = useState(false); const [editText, setEditText] = useState(''); const dragState = useRef(null); const elRef = useRef(null); const display = precision != null ? Number(value).toFixed(precision) : String(value); const clamp = useCallback((v) => { if (min != null && v < min) v = min; if (max != null && v > max) v = max; return v; }, [min, max]); const onPointerDown = useCallback((e) => { if (editing) return; e.preventDefault(); dragState.current = { startX: e.clientX, startVal: Number(value) }; elRef.current?.setPointerCapture(e.pointerId); }, [editing, value]); const onPointerMove = useCallback((e) => { if (!dragState.current) return; const dx = e.clientX - dragState.current.startX; const delta = dx * (step || 0.01); const raw = dragState.current.startVal + delta; const rounded = precision != null ? parseFloat(raw.toFixed(precision)) : Math.round(raw); onChange(clamp(rounded)); }, [step, precision, clamp, onChange]); const onPointerUp = useCallback((e) => { if (!dragState.current) return; const dx = Math.abs(e.clientX - dragState.current.startX); dragState.current = null; // If barely moved, enter text-edit mode if (dx < 3) { setEditText(display); setEditing(true); } }, [display]); const commitEdit = useCallback(() => { setEditing(false); const parsed = parseFloat(editText); if (!isNaN(parsed)) onChange(clamp(precision != null ? parseFloat(parsed.toFixed(precision)) : Math.round(parsed))); }, [editText, precision, clamp, onChange]); if (editing) { return ( setEditText(e.target.value)} onBlur={commitEdit} onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditing(false); }} /> ); } return (
{display}
); } // ── Collapsible section ─────────────────────────────────────────────── function CollapsibleSection({ title, defaultOpen, children }) { const [open, setOpen] = useState(defaultOpen); return (
{open && children}
); } // ── CustomNode component ────────────────────────────────────────────── function CustomNode({ id, data }) { const ctx = useContext(NodeContext); const def = data.definition; // Parse inputs into data handles and widgets const required = def.input.required || {}; const optional = def.input.optional || {}; const dataInputs = []; const widgets = []; 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 }); } else if (opts?.hidden) { hiddenWidgets.add(name); } else { widgets.push({ name, type, opts: opts || {}, socketType: SOCKET_WIDGET_TYPES.has(type) ? type : null }); } } // For manual-trigger nodes (Save), show progressive optional inputs: // show field_N only if field_(N-1) is connected (or N==0). const isProgressive = def.manual_trigger; const connectedInputs = useStore( useCallback( (s) => { if (!isProgressive) return null; const set = new Set(); for (const e of s.edges) { if (e.target === id) { const parts = e.targetHandle?.split('::'); if (parts) set.add(parts[1]); } } return set; }, [id, isProgressive], ), ); for (const [name, spec] of Object.entries(optional)) { const [type, opts] = Array.isArray(spec) ? spec : [spec, {}]; if (isProgressive && DATA_TYPES.has(type)) { // Progressive: show this slot only if it's the first or the previous is connected const match = name.match(/^field_(\d+)$/); if (match) { const idx = parseInt(match[1], 10); if (idx === 0 || (connectedInputs && connectedInputs.has(`field_${idx - 1}`))) { dataInputs.push({ name, type }); } continue; } } if (opts?.hidden) { hiddenWidgets.add(name); } else if (DATA_TYPES.has(type)) { dataInputs.push({ name, type }); } else { widgets.push({ name, type, opts: opts || {}, socketType: SOCKET_WIDGET_TYPES.has(type) ? type : null }); } } const outputs = def.output.map((type, i) => ({ name: def.output_name[i] || type, type, slot: i, })); const catColor = CAT_COLORS[def.category] || '#333'; const maxIORows = Math.max(dataInputs.length, outputs.length); return (
{/* Title */}
{data.label}
{/* I/O rows — pair inputs[i] with outputs[i] */} {Array.from({ length: maxIORows }, (_, i) => { const inp = dataInputs[i]; const out = outputs[i]; return (
{inp && ( <> {inp.name} )}
{out && ( <> {out.name} )}
); })} {/* Warning notification */} {data.warning && (
{data.warning}
)} {/* Widget rows */} {widgets.map((w) => (
{w.socketType && ( )}
))} {/* Manual trigger button (Save) */} {def.manual_trigger && (
)} {/* Interactive 3D surface view */} {data.meshData && ( Loading 3D...
}> )} {/* Collapsible preview image */} {data.previewImage && ( {typeof data.previewImage === 'string' ? (
preview
) : data.previewImage.kind === 'line_plot' ? ( ) : null}
)} {/* Interactive cross-section overlay */} {data.overlay && hiddenWidgets.has('x1') && ( Loading...
}> {data.overlay.kind === 'line_plot' ? ( ) : data.overlay.kind === 'crop_box' ? ( ) : ( )} )} {/* Collapsible table data */} {data.tableRows && data.tableRows.length > 0 && (
{data.tableRows.map((row, i) => { let line; if (row.quantity !== undefined) { const val = typeof row.value === 'number' ? row.value.toExponential(3) : row.value; line = `${row.quantity}: ${val} ${row.unit || ''}`; } else { line = Object.entries(row) .slice(0, 3) .map(([k, v]) => `${k}: ${typeof v === 'number' ? v.toExponential(2) : v}`) .join(' '); } return
{line}
; })}
)} ); } // ── Widget renderer ─────────────────────────────────────────────────── function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFileBrowser }) { const { name, type, opts } = widget; const val = value ?? opts?.default ?? ''; // Combo / enum — type itself is the array of options if (Array.isArray(type)) { return ( <> ); } if (type === 'FILE_PICKER') { return ( <>
onChange(nodeId, name, e.target.value)} placeholder="Select file…" />
); } if (type === 'FLOAT') { if (opts?.slider) { const rawMin = opts?.min_widget ? widgetValues?.[opts.min_widget] : opts?.min; const rawMax = opts?.max_widget ? widgetValues?.[opts.max_widget] : opts?.max; const parsedMin = Number(rawMin); const parsedMax = Number(rawMax); let sliderMin = Number.isFinite(parsedMin) ? parsedMin : 0; let sliderMax = Number.isFinite(parsedMax) ? parsedMax : 1; if (sliderMax < sliderMin) [sliderMin, sliderMax] = [sliderMax, sliderMin]; const step = opts?.step ?? 0.01; const numericVal = Number(val); const clampedVal = Number.isFinite(numericVal) ? Math.min(sliderMax, Math.max(sliderMin, numericVal)) : sliderMin; return ( <>
onChange(nodeId, name, parseFloat(e.target.value))} /> {clampedVal.toFixed(4)}
); } return ( <> onChange(nodeId, name, v)} /> ); } if (type === 'INT') { return ( <> onChange(nodeId, name, v)} /> ); } if (type === 'BOOLEAN') { return ( <> onChange(nodeId, name, e.target.checked)} /> ); } // STRING and anything else return ( <> onChange(nodeId, name, e.target.value)} /> ); } export default memo(CustomNode);