import React, { useContext, useRef, useCallback, useState, useEffect, memo, lazy, Suspense } from 'react'; import { Handle, NodeResizeControl, Position, useStore } from '@xyflow/react'; import { marked } from 'marked'; import LinePlotOverlay from './LinePlotOverlay'; marked.use({ breaks: true, gfm: true }); 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')); const AngleMeasureOverlay = lazy(() => import('./AngleMeasureOverlay')); const ThresholdHistogram = lazy(() => import('./ThresholdHistogram')); const RadialProfileOverlay = lazy(() => import('./RadialProfileOverlay')); import TextNoteNode from './TextNoteNode'; import { getSpecTypeAndOptions, isDataSocketSpec, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS, } from './constants'; import { getGroupMinimumSize } from './groupSizing'; import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout'; import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns, parseNumberWithUnit, formatSI, parseSI } from './valueFormatting'; import type { NodeData, NodeContextValue, InputSpec, InputOptions, WidgetDescriptor, PreviewPayload, OverlayData } from './types'; // ── Extended context type (adds methods not in base NodeContextValue) ── interface ExtendedNodeContextValue extends NodeContextValue { onRenameGroup?: (id: string, label: string) => void; onResizeGroup?: (id: string, params: any) => void; onToggleGroupCollapse?: (id: string) => void; onUngroup?: (id: string) => void; onManualTrigger?: (id: string) => void; onRuntimeValuesChange?: (nodeId: string, values: Record) => void; } // ── Helper types ───────────────────────────────────────────────────── interface ColorMapStop { position: number; color: string; } interface DragState { startX: number; startVal: number; } interface DataInput { name: string; type: string | string[]; label: string; } type WidgetEntry = WidgetDescriptor; // ── Context (provided by App) ───────────────────────────────────────── export const NodeContext = React.createContext(null); function parseProxyHandle(handleId: string | null | undefined) { 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 }: { id: string; data: NodeData }) { 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: any) => { const node = s.nodeLookup?.get(id) || s.nodes?.find((candidate: any) => candidate.id === id); return !!node?.selected; }, [id], ), ); const groupMinSize = useStore( useCallback( (s: any) => getGroupMinimumSize( (s.nodes || []).filter((candidate: any) => 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
)}
); } interface PreviewBoundaryProps { resetKey?: string | null; fallbackImage?: string | null; children?: React.ReactNode; } interface PreviewBoundaryState { hasError: boolean; } class PreviewBoundary extends React.Component { constructor(props: PreviewBoundaryProps) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error: unknown) { console.error('[tono] preview render failed', error); } componentDidUpdate(prevProps: PreviewBoundaryProps) { 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 }: { value: unknown; step: number | undefined; min: number | undefined; max: number | undefined; precision: number | null | undefined; onChange: (v: number) => void; }) { const [editing, setEditing] = useState(false); const [editText, setEditText] = useState(''); const dragState = useRef(null); const elRef = useRef(null); const display = precision != null ? formatSI(Number(value), precision) : String(value); const clamp = useCallback((v: number) => { let clamped = v; if (min != null && clamped < min) clamped = min; if (max != null && clamped > max) clamped = max; return clamped; }, [min, max]); const onPointerDown = useCallback((e: React.PointerEvent) => { if (editing) return; e.preventDefault(); dragState.current = { startX: e.clientX, startVal: Number(value) }; elRef.current?.setPointerCapture(e.pointerId); }, [editing, value]); const onPointerMove = useCallback((e: React.PointerEvent) => { 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 ? raw : Math.round(raw); onChange(clamp(rounded)); }, [step, precision, clamp, onChange]); const onPointerUp = useCallback((e: React.PointerEvent) => { 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: React.WheelEvent) => { 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 ? raw : Math.round(raw); onChange(clamp(rounded)); }, [editing, step, value, precision, onChange, clamp]); const commitEdit = useCallback(() => { setEditing(false); const parsed = parseSI(editText); if (!isNaN(parsed)) onChange(clamp(precision != null ? parsed : 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 }: { title: string; defaultOpen: boolean; children?: React.ReactNode; }) { const [open, setOpen] = useState(defaultOpen); return (
{open && children}
); } function LayerGalleryPreview({ overlay }: { overlay: PreviewPayload }) { const layers = Array.isArray(overlay?.layers) ? overlay.layers : []; const [index, setIndex] = useState(0); // Reset to 0 only when the layer names change (different file/channels loaded), // not on every graph re-run which produces a new overlay object reference. const layerNamesKey = layers.map((l: { name?: string; image: string }) => l.name ?? '').join('\0'); const prevLayerNamesKeyRef = useRef(layerNamesKey); useEffect(() => { if (layerNamesKey !== prevLayerNamesKeyRef.current) { prevLayerNamesKeyRef.current = layerNamesKey; setIndex(0); } }, [layerNamesKey]); 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: Array>) { const names: string[] = []; 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: unknown) { 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: unknown): { value: number; unit: string } | { valueText: string; unitText: string } | null { if (typeof scalarValue === 'number') { return Number.isFinite(scalarValue) ? { value: scalarValue, unit: '' } : null; } if (!scalarValue || typeof scalarValue !== 'object') return null; const sv = scalarValue as Record; const raw = sv.value; if (typeof raw === 'string') { return { valueText: raw, unitText: typeof sv.unit === 'string' ? sv.unit : '' }; } const numeric = Number(raw); if (!Number.isFinite(numeric)) return null; return { value: numeric, unit: typeof sv.unit === 'string' ? sv.unit : '', }; } function formatScalarDisplay(scalarValue: unknown): { valueText: string; unitText: string } | null { const payload = getScalarPayload(scalarValue); if (!payload) return null; if ('valueText' in payload) return payload as { valueText: string; unitText: string }; 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: unknown) { 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: any, nodeId: string, inputName: string) { const targetHandle = `input::${inputName}::`; const edge = store.edges?.find((e: any) => 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: any, nodeId: string, inputName: string) { const targetHandle = `input::${inputName}::`; const edge = store.edges?.find((e: any) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle)); if (!edge) return null; return store.nodeLookup?.get(edge.source) || store.nodes?.find((n: any) => n.id === edge.source) || null; } function getConnectedOutputInfo(store: any, nodeId: string, inputName: string) { const targetHandle = `input::${inputName}::`; const edge = store.edges?.find((e: any) => 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: any) => 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: any, nodeId: string, coordPairInputName: string) { const nodes = store.nodes; const edges = store.edges; if (!nodes || !edges) return null; const findNode = (nid: string) => nodes.find((n: any) => n.id === nid); // 1. Find the edge feeding this node's COORDPAIR input const cpEdge = edges.find( (e: any) => 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: string) => { const edge = edges.find( (e: any) => 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: unknown) { 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: InputOptions | undefined) { 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: WidgetEntry, sourceType: string | null) { 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 sourceType != null && allowed.includes(sourceType); } function widgetVisibleForWidgetValues(widget: WidgetEntry, widgetValues: Record) { 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: WidgetEntry, connectedInputs: Set | null) { 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: WidgetEntry, visibleInputs: Set | null) { 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: WidgetEntry) { 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: unknown, 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: unknown): ColorMapStop[] { let parsed: unknown = 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: ColorMapStop[] = (parsed as unknown[]) .map((stop: any) => { 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: ColorMapStop, b: ColorMapStop) => 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: ColorMapStop[]) { return JSON.stringify(stops.map((stop: ColorMapStop, index: number) => ({ position: index === 0 ? 0 : index === stops.length - 1 ? 1 : Number(stop.position.toFixed(4)), color: normalizeHexColor(stop.color, '#000000'), }))); } function colorMapGradient(stops: ColorMapStop[]) { return `linear-gradient(90deg, ${stops.map((stop: ColorMapStop) => `${stop.color} ${Math.round(stop.position * 1000) / 10}%`).join(', ')})`; } function ColorMapStopsEditor({ nodeId, name, value, onChange }: { nodeId: string; name: string; value: unknown; onChange: (nodeId: string, name: string, value: unknown) => void; }) { const stops = parseColorMapStops(value); const commitStops = useCallback((nextStops: ColorMapStop[]) => { const ordered = [...nextStops].sort((a: ColorMapStop, b: ColorMapStop) => 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: number, patch: Partial) => { const next = stops.map((stop: ColorMapStop, stopIndex: number) => (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: number) => { if (stops.length <= 2) return; commitStops(stops.filter((_: ColorMapStop, stopIndex: number) => 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: ColorMapStop, index: number) => { 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 }: { rows: Array> }) { const [query, setQuery] = useState(''); const scrollRef = useRef(null); const isInsideRef = useRef(false); const pointerEnteredAtRef = useRef(0); const lastWheelAtRef = useRef(0); const gestureStartedInsideRef = useRef(false); useEffect(() => { const el = scrollRef.current; if (!el) return; const onEnter = () => { isInsideRef.current = true; pointerEnteredAtRef.current = Date.now(); }; const onLeave = () => { isInsideRef.current = false; }; const onWheel = (e: WheelEvent) => { const now = Date.now(); const msSinceLastWheel = now - lastWheelAtRef.current; const msSinceEnter = now - pointerEnteredAtRef.current; lastWheelAtRef.current = now; if (msSinceLastWheel > 300) { // First event of a new gesture — only capture if pointer was already settled inside gestureStartedInsideRef.current = isInsideRef.current && msSinceEnter > 100; } if (gestureStartedInsideRef.current) { e.stopPropagation(); } }; el.addEventListener('wheel', onWheel, { passive: false }); el.addEventListener('pointerenter', onEnter); el.addEventListener('pointerleave', onLeave); return () => { el.removeEventListener('wheel', onWheel); el.removeEventListener('pointerenter', onEnter); el.removeEventListener('pointerleave', onLeave); }; }, []); 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: string) => { 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 ''; }; const filteredRows = query.trim() ? rows.filter((row: Record) => columns.some((col: string) => { const cell = formatTableRowCell(row, col); return String(cell).toLowerCase().includes(query.toLowerCase()); }) ) : rows; return (
{rows.length > 5 && (
e.stopPropagation()} > setQuery(e.target.value)} />
)}
{hasMeasurementLayout && ( )} {columns.map((column) => ( ))} {filteredRows.map((row: Record, rowIndex: number) => ( {columns.map((column) => { const value = row?.[column]; const displayValue = formatTableRowCell(row, column); return ( ); })} ))}
{column}
{displayValue}
); } // ── CustomNode component ────────────────────────────────────────────── function CustomNode({ id, data }: { id: string; data: NodeData }) { const ctx = useContext(NodeContext); const def = data.definition; const scalarDisplay = formatScalarDisplay(data.scalarValue); const processingTimeText = formatProcessingTime(data.processingTimeMs); const nodeWidth = useStore( useCallback((s: any) => { const node = s.nodeLookup.get(id); return node?.width ?? undefined; }, [id]), ); const connectedPathInfo = useStore( useCallback((s: any) => getConnectedOutputInfo(s, id, 'path'), [id]), ); // Find the COORDPAIR input name (if any) so we can resolve live upstream positions const coordPairInputName = React.useMemo(() => { if (!def) return null; 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: any) => coordPairInputName ? resolveLiveCoordPair(s, id, coordPairInputName) : null, [id, coordPairInputName], ), (a: any, b: any) => { 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]; }, ); const overlayCoord = useCallback( (key: 'x1' | 'y1' | 'x2' | 'y2', liveIdx: number, locked: boolean) => { if (locked) return (liveCoordPair?.[liveIdx] ?? data.overlay?.[key]) as number; return (data.widgetValues[key] ?? data.overlay?.[key]) as number; }, [liveCoordPair, data.overlay, data.widgetValues], ); // Parse inputs into data handles and widgets const required = def?.input?.required || {}; const optional = def?.input?.optional || {}; const dataInputs: DataInput[] = []; const widgets: WidgetEntry[] = []; const visibleInputNames = new Set(); const hiddenWidgets = new Set(); for (const [name, spec] of Object.entries(required)) { const [type, opts] = getSpecTypeAndOptions(spec as InputSpec); if (isDataSocketSpec(spec as InputSpec)) { dataInputs.push({ name, type, label: formatUiLabel(opts?.label || name) }); visibleInputNames.add(name); } else if (opts?.hidden) { hiddenWidgets.add(name); } else if (opts?.socket_only) { 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 as string) ? type as string : undefined }); } } // 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: any) => { 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: any) => { const sourceTypes: Record = {}; const allInputs = { ...required, ...optional }; for (const name of Object.keys(allInputs)) { sourceTypes[name] = getSourceTypeForInput(s, id, name); } return sourceTypes; }, [id, required, optional], ), ); if (data.className === 'Group') { return ; } if (data.className === 'TextNote') { return ; } for (const [name, spec] of Object.entries(optional)) { const [type, opts] = getSpecTypeAndOptions(spec as InputSpec); if (isProgressive && isDataSocketSpec(spec as InputSpec)) { // Progressive: show this slot only if it's the first or the previous // is connected. If the socket also carries `show_when_source_type`, // the gating input must be connected to a matching source type before // any slot in the chain is revealed — this lets Save's layer stack // stay hidden until `value` is a DataField or Image. const match = name.match(/^field_(\d+)$/); if (match) { const idx = parseInt(match[1], 10); const sourceTypeRules = opts?.show_when_source_type; let sourceTypeOk = true; if (sourceTypeRules && typeof sourceTypeRules === 'object') { const gateInput = Object.keys(sourceTypeRules)[0]; const allowed = Array.isArray(sourceTypeRules[gateInput]) ? sourceTypeRules[gateInput] : []; const actualSourceType = connectedSourceTypes?.[gateInput] ?? null; sourceTypeOk = actualSourceType != null && allowed.includes(actualSourceType); } const progressiveOk = idx === 0 || (connectedInputs && connectedInputs.has(`field_${idx - 1}`)); if (sourceTypeOk && progressiveOk) { dataInputs.push({ name, type, label: formatUiLabel(opts?.label || name) }); visibleInputNames.add(name); } continue; } } if (opts?.hidden) { hiddenWidgets.add(name); } else if (isDataSocketSpec(spec as InputSpec) || opts?.socket_only) { 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 as string) ? type as string : undefined }); } } 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)); // Computed directly from React props so it updates reliably when tableRows changes. const nodeTableMeasurementChoices = getMeasurementChoices(data.tableRows || []); const inlineWidgetsByInput = new Map(); const topWidgets: WidgetEntry[] = []; const standaloneWidgets: WidgetEntry[] = []; 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: string, i: number) => ({ 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' || data.overlay.kind === 'threshold_histogram' || data.overlay.kind === 'radial_profile' ); 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 === 'angle_measure' ? 'Angle' : data.overlay?.kind === 'crop_box' ? 'Crop' : data.overlay?.kind === 'cursor_points' ? 'Cursors' : data.overlay?.kind === 'line_plot' ? 'Line Plot' : data.overlay?.kind === 'radial_profile' ? 'Radial Profile' : '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 ( <> {ctx?.executingNodeId === id && }> {data.overlay!.kind === 'line_plot' ? ( ) : data.overlay!.kind === 'crop_box' ? ( ) : data.overlay!.kind === 'cursor_points' ? ( ) : data.overlay!.kind === 'mask_paint' ? ( ) : data.overlay!.kind === 'markup' ? ( ) : data.overlay!.kind === 'threshold_histogram' ? ( ) : data.overlay!.kind === 'radial_profile' ? ( ) : data.overlay!.kind === 'angle_measure' ? ( ) : ( )} )} {/* Collapsible table data */} {data.tableRows && data.tableRows.length > 0 && ( )} {processingTimeText && (
{processingTimeText}
)}
); } // ── Editable value-box for text_input FLOAT widgets ────────────────── function TextInputValueBox({ val, placeholder, nodeId, name, label, hideLabel, onChange }: { val: unknown; placeholder: string; nodeId: string; name: string; label: string; hideLabel: boolean; onChange: (nodeId: string, name: string, value: unknown) => void; }) { const [editing, setEditing] = useState(false); const parsed = parseNumberWithUnit(val); const display = parsed ? formatScalarDisplay({ value: parsed.numeric, unit: parsed.unit }) : null; return ( <> {!hideLabel && }
!editing && setEditing(true)} > {editing ? ( onChange(nodeId, name, e.target.value)} onBlur={() => setEditing(false)} style={{ background: 'transparent', border: 'none', outline: 'none', color: 'inherit', font: 'inherit', textAlign: 'center', width: '100%', padding: 0, }} /> ) : display ? ( <> {display.valueText} {display.unitText && {display.unitText}} ) : ( {placeholder || '0'} )}
); } // ── Widget renderer ─────────────────────────────────────────────────── function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFileBrowser, hideLabel = false, measurementChoices }: { widget: WidgetEntry; nodeId: string; value: unknown; widgetValues: Record; onChange: (nodeId: string, name: string, value: unknown) => void; openFileBrowser: (callback: (files: any) => void, options?: unknown) => void; hideLabel?: boolean; measurementChoices: string[]; }) { 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: any) => { const inputName = getWidgetSourceInputName(opts); if (!inputName) return null; return getSourceTypeForInput(s, nodeId, inputName); }, [nodeId, opts], ), ); const dynamicTableColumns = useStore( useCallback( (s: any) => { const tableInputName = opts?.choices_from_table_input; if (!tableInputName) return []; const sourceType = getSourceTypeForInput(s, nodeId, tableInputName); if (sourceType !== 'DATA_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: any) => { if (!opts?.choices_from_measure_input) return []; const node = s.nodeLookup?.get(nodeId) || s.nodes?.find((n: any) => n.id === nodeId); const rows = node?.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: string[] = []; 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 && } ); } // ── Select dropdown helper ────────────────────────────────────────── const renderSelect = (options: string[], selected: string) => ( <> {!hideLabel && } ); // Combo / enum — type itself is the array of options if (Array.isArray(type)) { return renderSelect(type, (val || type[0]) as string); } if (type === 'STRING' && dynamicTypeChoices.length > 0) { return renderSelect(dynamicTypeChoices, dynamicTypeChoices.includes(String(val)) ? String(val) : dynamicTypeChoices[0]); } if (type === 'STRING' && opts?.choices_from_table_input && dynamicTableColumns.length > 0) { return renderSelect(dynamicTableColumns, dynamicTableColumns.includes(String(val)) ? String(val) : dynamicTableColumns[0]); } if (type === 'STRING' && opts?.choices_from_measure_input && dynamicMeasurementChoices.length > 0) { return renderSelect(dynamicMeasurementChoices, dynamicMeasurementChoices.includes(String(val)) ? String(val) : dynamicMeasurementChoices[0]); } 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 : '#ff0000'; 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 (opts?.text_input) { 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);