import React, { useContext, useRef, useCallback, useState, useEffect, memo, lazy, Suspense } from 'react'; import { Handle, NodeResizeControl, Position, useStore } from '@xyflow/react'; import LinePlotOverlay from './LinePlotOverlay'; 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')); import { DATA_TYPES, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS, } from './constants'; import { getGroupMinimumSize } from './groupSizing.js'; import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout.js'; import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns } from './valueFormatting.js'; // ── Context (provided by App) ───────────────────────────────────────── export const NodeContext = React.createContext(null); function parseProxyHandle(handleId) { const text = String(handleId || ''); if (!text.startsWith('group-proxy::')) return null; const parts = text.split('::'); if (parts.length < 5) return null; return { direction: parts[1], nodeId: parts[2], type: parts[3], realHandle: decodeURIComponent(parts.slice(4).join('::')), }; } function GroupNode({ id, data }) { const ctx = useContext(NodeContext); const proxyInputs = Array.isArray(data.proxyInputs) ? data.proxyInputs : []; const proxyOutputs = Array.isArray(data.proxyOutputs) ? data.proxyOutputs : []; const childCount = Number(data.childCount) || 0; const collapsed = !!data.collapsed; const maxRows = Math.max(proxyInputs.length, proxyOutputs.length, collapsed ? 1 : 0); const [isEditingLabel, setIsEditingLabel] = useState(false); const [draftLabel, setDraftLabel] = useState(String(data.label || 'group')); const labelInputRef = useRef(null); const selected = useStore( useCallback( (s) => { const node = s.nodeLookup?.get(id) || s.nodes?.find((candidate) => candidate.id === id); return !!node?.selected; }, [id], ), ); const groupMinSize = useStore( useCallback( (s) => getGroupMinimumSize( (s.nodes || []).filter((candidate) => String(candidate.parentId || '') === String(id)), ), [id], ), ); const displayLabel = String(data.label || 'group'); const labelFieldSize = Math.max(2, Math.min(40, String(draftLabel || displayLabel || 'group').length)); useEffect(() => { if (!isEditingLabel) { setDraftLabel(displayLabel); } }, [displayLabel, isEditingLabel]); useEffect(() => { if (!isEditingLabel) return; labelInputRef.current?.focus(); labelInputRef.current?.select(); }, [isEditingLabel]); const commitLabel = useCallback(() => { const nextLabel = String(draftLabel || '').trim() || 'group'; setIsEditingLabel(false); setDraftLabel(nextLabel); if (nextLabel !== displayLabel) { ctx.onRenameGroup?.(id, nextLabel); } }, [ctx, displayLabel, draftLabel, id]); const cancelLabelEdit = useCallback(() => { setDraftLabel(displayLabel); setIsEditingLabel(false); }, [displayLabel]); return ( <> {!collapsed && selected && ( ctx.onResizeGroup?.(id, params)} /> )}
{isEditingLabel ? ( setDraftLabel(event.target.value)} onBlur={commitLabel} onClick={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); commitLabel(); } else if (event.key === 'Escape') { event.preventDefault(); cancelLabelEdit(); } }} /> ) : ( )}
{collapsed ? ( <> {Array.from({ length: maxRows }, (_, index) => { const input = proxyInputs[index]; const output = proxyOutputs[index]; return (
{input && ( <> {formatUiLabel(input.label || input.name)} )}
{output && ( <> {formatUiLabel(output.label || output.name)} )}
); })}
{childCount} nodes
) : (
workflow group
{childCount} nodes
)}
); } 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 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); 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}
); } 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 (
{active.name || `Layer ${index + 1}`}
{index + 1} / {layers.length}
{active.name
); } function getMeasurementChoices(rows) { const names = []; for (const row of rows || []) { const quantity = row?.quantity; if (typeof quantity === 'string' && quantity && !names.includes(quantity)) { names.push(quantity); } } return names; } function formatScalarValue(value) { if (value == null || Number.isNaN(Number(value))) return '—'; const numeric = Number(value); if (!Number.isFinite(numeric)) return String(numeric); const abs = Math.abs(numeric); if (abs === 0) return '0'; if ((abs > 0 && abs < 1e-3) || abs >= 1e5) return numeric.toExponential(4); return numeric.toFixed(abs >= 100 ? 2 : 4).replace(/\.?0+$/, ''); } function getScalarPayload(scalarValue) { if (typeof scalarValue === 'number') { return Number.isFinite(scalarValue) ? { value: scalarValue, unit: '' } : null; } if (!scalarValue || typeof scalarValue !== 'object') return null; const numeric = Number(scalarValue.value); if (!Number.isFinite(numeric)) return null; return { value: numeric, unit: typeof scalarValue.unit === 'string' ? scalarValue.unit : '', }; } function formatScalarDisplay(scalarValue) { const payload = getScalarPayload(scalarValue); if (!payload) return null; if (payload.unit) { const prefixed = applySIPrefix(payload.value, payload.unit); if (prefixed.unitText !== payload.unit || prefixed.valueText !== formatNumericCell(payload.value)) { return { valueText: prefixed.valueText, unitText: prefixed.unitText, }; } return { valueText: formatScalarValue(payload.value), unitText: payload.unit, }; } return { valueText: formatScalarValue(payload.value), unitText: '', }; } function formatProcessingTime(value) { const ms = Number(value); if (!Number.isFinite(ms) || ms < 0) return null; if (ms < 1) return `${ms.toFixed(2)} ms`; if (ms < 10) return `${ms.toFixed(1)} ms`; if (ms < 1000) return `${Math.round(ms)} ms`; if (ms < 10000) return `${(ms / 1000).toFixed(2)} s`; return `${(ms / 1000).toFixed(1)} s`; } function getSourceTypeForInput(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 proxy = parseProxyHandle(edge.sourceHandle); if (proxy) return proxy.type || null; const parts = edge.sourceHandle.split('::'); return parts[2] || null; } function getSourceNodeForInput(store, nodeId, inputName) { const targetHandle = `input::${inputName}::`; const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle)); if (!edge) return null; 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 proxy = parseProxyHandle(edge.sourceHandle); const sourceNodeId = proxy?.nodeId || edge.source; const sourceHandle = proxy?.realHandle || edge.sourceHandle; const sourceNode = store.nodeLookup?.get(sourceNodeId) || store.nodes?.find((n) => n.id === sourceNodeId) || null; const slot = Number.parseInt(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, }; } /** * Resolve live COORDPAIR values by walking edges back to upstream Coordinate * nodes' widget values. Returns [x1, y1, x2, y2] (a flat array for stable * equality comparison) or null if the chain can't be fully resolved. * * Uses store.nodes (the reactive array) rather than nodeLookup so that * upstream widgetValues changes trigger re-renders. */ function resolveLiveCoordPair(store, nodeId, coordPairInputName) { const nodes = store.nodes; const edges = store.edges; if (!nodes || !edges) return null; const findNode = (nid) => nodes.find((n) => n.id === nid); // 1. Find the edge feeding this node's COORDPAIR input const cpEdge = edges.find( (e) => e.target === nodeId && e.targetHandle?.startsWith(`input::${coordPairInputName}::`) ); if (!cpEdge) return null; const cpNode = findNode(cpEdge.source); if (!cpNode) return null; // If the source node is a CoordinatePair, walk one more level to Coordinate nodes if (cpNode.data?.className === 'CoordinatePair') { const resolveCoord = (inputName) => { const edge = edges.find( (e) => e.target === cpNode.id && e.targetHandle?.startsWith(`input::${inputName}::`) ); if (!edge) return null; const srcNode = findNode(edge.source); if (!srcNode?.data?.widgetValues) return null; const x = srcNode.data.widgetValues.x; const y = srcNode.data.widgetValues.y; return (x != null && y != null) ? [x, y] : null; }; const a = resolveCoord('a'); const b = resolveCoord('b'); if (!a || !b) return null; return [a[0], a[1], b[0], b[1]]; } // If the source is a node with x1/y1/x2/y2 widgets (e.g. another CrossSection output) const wv = cpNode.data?.widgetValues; if (wv && wv.x1 != null && wv.y1 != null && wv.x2 != null && wv.y2 != null) { return [wv.x1, wv.y1, wv.x2, wv.y2]; } return 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 || opts?.choices_from_measure_input || Object.keys(opts?.show_when_source_type || {})[0]; } function widgetVisibleForSourceType(widget, sourceType) { const rules = widget?.opts?.show_when_source_type; if (!rules || typeof rules !== 'object') return true; const inputName = Object.keys(rules)[0]; const allowed = Array.isArray(rules[inputName]) ? rules[inputName] : []; if (allowed.length === 0) return true; 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 (
{stops.map((stop, index) => { const isEndpoint = index === 0 || index === stops.length - 1; return (
{isEndpoint ? (index === 0 ? 'min' : 'max') : `stop ${index}`} updateStop(index, { color: e.target.value })} /> {isEndpoint ? ( {index === 0 ? '0%' : '100%'} ) : ( updateStop(index, { position: Number(e.target.value) })} /> )}
); })}
); } function NodeTable({ rows }) { const columns = getTableColumns(rows); if (columns.length === 0) return null; const lowerColumns = columns.map((column) => String(column).toLowerCase()); const hasMeasurementLayout = ( lowerColumns.length === 3 && lowerColumns[0] === 'quantity' && lowerColumns[1] === 'value' && lowerColumns[2] === 'unit' ); const getColumnClass = (column) => { const lower = String(column).toLowerCase(); if (lower === 'value') return 'node-table-col-value'; if (lower === 'unit') return 'node-table-col-unit'; if (lower === 'quantity') return 'node-table-col-quantity'; return ''; }; return (
{hasMeasurementLayout && ( )} {columns.map((column) => ( ))} {rows.map((row, rowIndex) => ( {columns.map((column) => { const value = row?.[column]; const displayValue = formatTableRowCell(row, column); return ( ); })} ))}
{column}
{displayValue}
); } // ── CustomNode component ────────────────────────────────────────────── function CustomNode({ id, data }) { const ctx = useContext(NodeContext); if (data.className === 'Group') { return ; } const def = data.definition; const scalarDisplay = formatScalarDisplay(data.scalarValue); const processingTimeText = formatProcessingTime(data.processingTimeMs); const connectedPathInfo = useStore( useCallback((s) => getConnectedOutputInfo(s, id, 'path'), [id]), ); // Find the COORDPAIR input name (if any) so we can resolve live upstream positions const coordPairInputName = React.useMemo(() => { const allInputs = { ...def.input.required, ...def.input.optional }; for (const [name, spec] of Object.entries(allInputs)) { const type = Array.isArray(spec) ? spec[0] : spec; if (type === 'COORDPAIR') return name; } return null; }, [def]); // Returns [x1, y1, x2, y2] or null — flat array for cheap equality check const liveCoordPair = useStore( useCallback( (s) => coordPairInputName ? resolveLiveCoordPair(s, id, coordPairInputName) : null, [id, coordPairInputName], ), (a, b) => { if (a === b) return true; if (!a || !b) return false; return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]; }, ); // Parse inputs into data handles and widgets const required = def.input.required || {}; const optional = def.input.optional || {}; 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, label: formatUiLabel(opts?.label || name) }); visibleInputNames.add(name); } 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) => { 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], ), ); const connectedSourceTypes = useStore( useCallback( (s) => { const sourceTypes = {}; const allInputs = { ...required, ...optional }; for (const name of Object.keys(allInputs)) { sourceTypes[name] = getSourceTypeForInput(s, id, name); } return sourceTypes; }, [id, required, optional], ), ); 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, label: formatUiLabel(opts?.label || name) }); visibleInputNames.add(name); } continue; } } if (opts?.hidden) { hiddenWidgets.add(name); } else if (DATA_TYPES.has(type)) { dataInputs.push({ name, type, label: formatUiLabel(opts?.label || name) }); visibleInputNames.add(name); } else { widgets.push({ name, type, opts: opts || {}, socketType: SOCKET_WIDGET_TYPES.has(type) ? type : null }); } } const dataInputByName = new Map(dataInputs.map((input) => [input.name, input])); const widgetsVisibleByDefinition = widgets.filter((w) => ( widgetVisibleForSourceType(w, connectedSourceTypes?.[getWidgetSourceInputName(w.opts)]) && widgetVisibleForWidgetValues(w, data.widgetValues) && widgetVisibleForInputVisibility(w, visibleInputNames) )); const combinedInputNameByWidgetName = buildCombinedInputNameByWidgetName( widgetsVisibleByDefinition, dataInputs, ); const visibleWidgets = widgetsVisibleByDefinition.filter((widget) => ( combinedInputNameByWidgetName.has(widget.name) || !widgetHiddenByConnectedInput(widget, connectedInputs) )); const combinedInputNames = new Set(combinedInputNameByWidgetName.values()); const renderedDataInputs = dataInputs.filter((input) => !combinedInputNames.has(input.name)); 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: formatUiLabel(def.output_name[i] || type), type, slot: i, })); const catColor = CAT_COLORS[def.category] || 'var(--fallback-cat)'; 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' || 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 === 'cursor_points' ? 'Cursors' : data.overlay?.kind === 'line_plot' ? 'Line Plot' : 'Cross Section'); const headerMeta = (() => { if (data.className === 'Folder') { return getBasename(data.widgetValues?.folder); } if (data.className === 'Image') { return getBasename(connectedPathInfo?.path || data.widgetValues?.filename); } if (data.className === 'ImageDemo') { return getBasename(data.widgetValues?.name); } return ''; })(); return (
{/* Title */}
{data.label} {headerMeta && {headerMeta}}
{topWidgets.length > 0 && (
{topWidgets.map((w) => { const combinedInputName = combinedInputNameByWidgetName.get(w.name) || null; const socketInput = combinedInputName ? dataInputByName.get(combinedInputName) : null; const socketType = w.socketType || socketInput?.type; const socketName = w.socketType ? w.name : socketInput?.name; return (
{socketType && socketName && ( )} {!!( (w.socketType && connectedInputs?.has(w.name)) || (combinedInputName && connectedInputs?.has(combinedInputName)) ) ? ( ) : ( )}
); })}
)} {/* I/O rows — pair inputs[i] with outputs[i] */} {Array.from({ length: maxIORows }, (_, i) => { const inp = renderedDataInputs[i]; const out = outputs[i]; return (
{inp && ( <> {inp.label || inp.name} {inlineWidgetsByInput.has(inp.name) && (
)} )}
{out && ( <> {out.name} )}
); })} {/* Warning notification */} {data.warning && (
{data.warning}
)} {scalarDisplay && (
Value
{scalarDisplay.valueText} {scalarDisplay.unitText && ( {scalarDisplay.unitText} )}
)} {/* Widget rows */} {standaloneWidgets.map((w) => { const combinedInputName = combinedInputNameByWidgetName.get(w.name) || null; const socketInput = combinedInputName ? dataInputByName.get(combinedInputName) : null; const socketType = w.socketType || socketInput?.type; const socketName = w.socketType ? w.name : socketInput?.name; return (
{socketType && socketName && ( )} {(w.socketType && connectedInputs?.has(w.name)) || (combinedInputName && connectedInputs?.has(combinedInputName)) ? ( ) : ( )}
); })} {/* Manual trigger button (Save) */} {def.manual_trigger && (
)} {/* Interactive 3D surface view */} {data.meshData && ( Loading 3D...
}> )} {/* Collapsible preview image */} {data.previewImage && !hidePreviewForInteractiveMask && !(hasInteractiveLineOverlay && typeof data.previewImage === 'object' && data.previewImage.kind === 'line_plot') && ( {typeof data.previewImage === 'string' ? (
preview
) : data.previewImage.kind === 'layer_gallery' ? ( ) : data.previewImage.kind === 'line_plot' ? ( ) : null}
)} {/* Interactive cross-section overlay */} {hasInteractiveOverlay && ( Loading...
}> {data.overlay.kind === 'line_plot' ? ( ) : data.overlay.kind === 'crop_box' ? ( ) : data.overlay.kind === 'cursor_points' ? ( ) : data.overlay.kind === 'mask_paint' ? ( ) : data.overlay.kind === 'markup' ? ( ) : ( )} )} {/* Collapsible table data */} {data.tableRows && data.tableRows.length > 0 && ( )} {processingTimeText && (
{processingTimeText}
)}
); } // ── Widget renderer ─────────────────────────────────────────────────── function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFileBrowser, hideLabel = false }) { const { name, type, opts } = widget; const label = formatUiLabel(opts?.label || name); const val = value ?? opts?.default ?? ''; const placeholder = opts?.placeholder || ''; const dynamicSourceType = useStore( useCallback( (s) => { const inputName = getWidgetSourceInputName(opts); if (!inputName) return null; return getSourceTypeForInput(s, nodeId, inputName); }, [nodeId, opts], ), ); const dynamicTableColumns = useStore( useCallback( (s) => { const tableInputName = opts?.choices_from_table_input; if (!tableInputName) return []; const sourceType = getSourceTypeForInput(s, nodeId, tableInputName); if (sourceType !== 'RECORD_TABLE') return []; const sourceNode = getSourceNodeForInput(s, nodeId, tableInputName); const rows = sourceNode?.data?.tableRows; return Array.isArray(rows) ? getTableColumns(rows) : []; }, [nodeId, opts?.choices_from_table_input], ), ); const dynamicMeasurementChoices = useStore( useCallback( (s) => { const measurementInputName = opts?.choices_from_measure_input; if (!measurementInputName) return []; const sourceType = getSourceTypeForInput(s, nodeId, measurementInputName); if (sourceType !== 'MEASURE_TABLE') return []; const sourceNode = getSourceNodeForInput(s, nodeId, measurementInputName); const rows = sourceNode?.data?.tableRows; return Array.isArray(rows) ? getMeasurementChoices(rows) : []; }, [nodeId, opts?.choices_from_measure_input], ), ); const dynamicTypeChoices = (() => { const byType = opts?.choices_by_source_type; if (!byType) return []; if (dynamicSourceType) { return Array.isArray(byType[dynamicSourceType]) ? byType[dynamicSourceType] : []; } const merged = []; for (const choices of Object.values(byType)) { if (!Array.isArray(choices)) continue; for (const choice of choices) { if (!merged.includes(choice)) merged.push(choice); } } return merged; })(); useEffect(() => { if (!opts?.choices_from_table_input || dynamicTableColumns.length === 0) return; const current = String(val ?? ''); if (dynamicTableColumns.includes(current)) return; const preferred = dynamicTableColumns.includes('value') ? 'value' : dynamicTableColumns[0]; if (preferred != null) onChange(nodeId, name, preferred); }, [dynamicTableColumns, name, nodeId, onChange, opts?.choices_from_table_input, val]); useEffect(() => { if (!opts?.choices_from_measure_input || dynamicMeasurementChoices.length === 0) return; const current = String(val ?? ''); if (dynamicMeasurementChoices.includes(current)) return; if (dynamicMeasurementChoices[0] != null) onChange(nodeId, name, dynamicMeasurementChoices[0]); }, [dynamicMeasurementChoices, name, nodeId, onChange, opts?.choices_from_measure_input, val]); useEffect(() => { if (dynamicTypeChoices.length === 0) return; const current = String(val ?? ''); if (dynamicTypeChoices.includes(current)) return; onChange(nodeId, name, dynamicTypeChoices[0]); }, [dynamicTypeChoices, name, nodeId, onChange, val]); if (opts?.colormap_stops) { return ( <> {!hideLabel && } ); } // Combo / enum — type itself is the array of options if (Array.isArray(type)) { return ( <> {!hideLabel && } ); } if (type === 'STRING' && dynamicTypeChoices.length > 0) { const selected = dynamicTypeChoices.includes(String(val)) ? String(val) : dynamicTypeChoices[0]; return ( <> {!hideLabel && } ); } if (type === 'STRING' && opts?.choices_from_table_input && dynamicTableColumns.length > 0) { const selected = dynamicTableColumns.includes(String(val)) ? String(val) : dynamicTableColumns[0]; return ( <> {!hideLabel && } ); } if (type === 'STRING' && opts?.choices_from_measure_input && dynamicMeasurementChoices.length > 0) { const selected = dynamicMeasurementChoices.includes(String(val)) ? String(val) : dynamicMeasurementChoices[0]; return ( <> {!hideLabel && } ); } if (type === 'FILE_PICKER' || type === 'FOLDER_PICKER') { const isFolderPicker = type === 'FOLDER_PICKER'; return ( <> {!hideLabel && }
onChange(nodeId, name, e.target.value)} placeholder={placeholder || (isFolderPicker ? 'Select folder…' : 'Select file…')} />
); } if (type === 'STRING' && opts?.color_picker) { const normalized = typeof val === 'string' && /^#[0-9a-fA-F]{6}$/.test(val) ? val : 'var(--shape-default)'; return ( <> {!hideLabel && } onChange(nodeId, name, e.target.value)} /> ); } if (type === 'BUTTON') { const updates = opts?.set_widgets && typeof opts.set_widgets === 'object' ? Object.entries(opts.set_widgets) : []; return ( ); } 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 ( <> {!hideLabel && }
onChange(nodeId, name, parseFloat(e.target.value))} /> {clampedVal.toFixed(4)}
); } return ( <> {!hideLabel && } onChange(nodeId, name, v)} /> ); } if (type === 'INT') { return ( <> {!hideLabel && } onChange(nodeId, name, v)} /> ); } if (type === 'BOOLEAN') { return ( <> {!hideLabel && } onChange(nodeId, name, e.target.checked)} /> ); } // STRING and anything else return ( <> {!hideLabel && } onChange(nodeId, name, e.target.value)} /> ); } export default memo(CustomNode);