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 (
);
}
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' ? (
) : 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);