577 lines
19 KiB
JavaScript
577 lines
19 KiB
JavaScript
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 (
|
|
<div className="node-preview">
|
|
<img src={this.props.fallbackImage} alt="preview fallback" draggable={false} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="node-preview" style={{ color: '#94a3b8', padding: 8 }}>
|
|
Preview unavailable.
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── 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 (
|
|
<input
|
|
className="nodrag drag-number-edit"
|
|
type="text"
|
|
autoFocus
|
|
value={editText}
|
|
onChange={(e) => setEditText(e.target.value)}
|
|
onBlur={commitEdit}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditing(false); }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={elRef}
|
|
className="nodrag drag-number"
|
|
onPointerDown={onPointerDown}
|
|
onPointerMove={onPointerMove}
|
|
onPointerUp={onPointerUp}
|
|
>
|
|
<span className="drag-number-val">{display}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Collapsible section ───────────────────────────────────────────────
|
|
|
|
function CollapsibleSection({ title, defaultOpen, children }) {
|
|
const [open, setOpen] = useState(defaultOpen);
|
|
return (
|
|
<div className="collapsible">
|
|
<button
|
|
className="nodrag collapsible-toggle"
|
|
onClick={() => setOpen((o) => !o)}
|
|
>
|
|
<span className="collapsible-arrow">{open ? '▾' : '▸'}</span>
|
|
{title}
|
|
</button>
|
|
{open && children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<div className="custom-node">
|
|
{/* Title */}
|
|
<div className="node-title drag-handle" style={{ background: catColor }}>
|
|
{data.label}
|
|
</div>
|
|
|
|
<div className="node-body">
|
|
{/* I/O rows — pair inputs[i] with outputs[i] */}
|
|
{Array.from({ length: maxIORows }, (_, i) => {
|
|
const inp = dataInputs[i];
|
|
const out = outputs[i];
|
|
return (
|
|
<div className="io-row" key={`io-${i}`}>
|
|
<div className="io-left">
|
|
{inp && (
|
|
<>
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
id={`input::${inp.name}::${inp.type}`}
|
|
className="typed-handle"
|
|
style={{ background: TYPE_COLORS[inp.type] || '#999' }}
|
|
/>
|
|
<span className="io-label">{inp.name}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="io-right">
|
|
{out && (
|
|
<>
|
|
<span className="io-label">{out.name}</span>
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id={`output::${out.slot}::${out.type}`}
|
|
className="typed-handle"
|
|
style={{ background: TYPE_COLORS[out.type] || '#999' }}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Warning notification */}
|
|
{data.warning && (
|
|
<div className="node-warning">{data.warning}</div>
|
|
)}
|
|
|
|
{/* Widget rows */}
|
|
{widgets.map((w) => (
|
|
<div className={`widget-row${w.socketType ? ' widget-row-socket' : ''}`} key={w.name}>
|
|
{w.socketType && (
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
id={`input::${w.name}::${w.socketType}`}
|
|
className="typed-handle"
|
|
style={{ background: TYPE_COLORS[w.socketType] || '#999' }}
|
|
/>
|
|
)}
|
|
<WidgetControl
|
|
widget={w}
|
|
nodeId={id}
|
|
value={data.widgetValues[w.name]}
|
|
widgetValues={data.widgetValues}
|
|
onChange={ctx.onWidgetChange}
|
|
openFileBrowser={ctx.openFileBrowser}
|
|
/>
|
|
</div>
|
|
))}
|
|
|
|
{/* Manual trigger button (Save) */}
|
|
{def.manual_trigger && (
|
|
<div className="widget-row">
|
|
<button
|
|
className="nodrag btn btn-primary"
|
|
style={{ flex: 1 }}
|
|
onClick={() => ctx.onManualTrigger?.(id)}
|
|
>
|
|
Save to Disk
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Interactive 3D surface view */}
|
|
{data.meshData && (
|
|
<CollapsibleSection title="3D View" defaultOpen={true}>
|
|
<Suspense fallback={<div className="node-preview" style={{color:'#64748b',padding:4}}>Loading 3D...</div>}>
|
|
<SurfaceView meshData={data.meshData} />
|
|
</Suspense>
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{/* Collapsible preview image */}
|
|
{data.previewImage && (
|
|
<CollapsibleSection title="Preview" defaultOpen={true}>
|
|
<PreviewBoundary
|
|
resetKey={typeof data.previewImage === 'string' ? data.previewImage : JSON.stringify({
|
|
kind: data.previewImage.kind,
|
|
len: data.previewImage.line?.length,
|
|
})}
|
|
fallbackImage={typeof data.previewImage === 'object' ? data.previewImage.fallback_image : null}
|
|
>
|
|
{typeof data.previewImage === 'string' ? (
|
|
<div className="node-preview">
|
|
<img src={data.previewImage} alt="preview" draggable={false} />
|
|
</div>
|
|
) : data.previewImage.kind === 'line_plot' ? (
|
|
<LinePlotOverlay overlay={data.previewImage} interactive={false} />
|
|
) : null}
|
|
</PreviewBoundary>
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{/* Interactive cross-section overlay */}
|
|
{data.overlay && hiddenWidgets.has('x1') && (
|
|
<CollapsibleSection title={data.overlay.kind === 'crop_box' ? 'Crop' : 'Cross Section'} defaultOpen={true}>
|
|
<Suspense fallback={<div className="node-preview" style={{color:'#64748b',padding:4}}>Loading...</div>}>
|
|
{data.overlay.kind === 'line_plot' ? (
|
|
<LinePlotOverlay
|
|
overlay={data.overlay}
|
|
x1={data.overlay.a_locked ? data.overlay.x1 : (data.widgetValues.x1 ?? data.overlay.x1)}
|
|
x2={data.overlay.b_locked ? data.overlay.x2 : (data.widgetValues.x2 ?? data.overlay.x2)}
|
|
aLocked={data.overlay.a_locked}
|
|
bLocked={data.overlay.b_locked}
|
|
nodeId={id}
|
|
onWidgetChange={ctx.onWidgetChange}
|
|
/>
|
|
) : data.overlay.kind === 'crop_box' ? (
|
|
<CropBoxOverlay
|
|
image={data.overlay.image}
|
|
x1={data.overlay.a_locked ? data.overlay.x1 : (data.widgetValues.x1 ?? data.overlay.x1)}
|
|
y1={data.overlay.a_locked ? data.overlay.y1 : (data.widgetValues.y1 ?? data.overlay.y1)}
|
|
x2={data.overlay.b_locked ? data.overlay.x2 : (data.widgetValues.x2 ?? data.overlay.x2)}
|
|
y2={data.overlay.b_locked ? data.overlay.y2 : (data.widgetValues.y2 ?? data.overlay.y2)}
|
|
aLocked={data.overlay.a_locked}
|
|
bLocked={data.overlay.b_locked}
|
|
nodeId={id}
|
|
onWidgetChange={ctx.onWidgetChange}
|
|
/>
|
|
) : (
|
|
<CrossSectionOverlay
|
|
image={data.overlay.image}
|
|
x1={data.overlay.a_locked ? data.overlay.x1 : (data.widgetValues.x1 ?? data.overlay.x1)}
|
|
y1={data.overlay.a_locked ? data.overlay.y1 : (data.widgetValues.y1 ?? data.overlay.y1)}
|
|
x2={data.overlay.b_locked ? data.overlay.x2 : (data.widgetValues.x2 ?? data.overlay.x2)}
|
|
y2={data.overlay.b_locked ? data.overlay.y2 : (data.widgetValues.y2 ?? data.overlay.y2)}
|
|
aLocked={data.overlay.a_locked}
|
|
bLocked={data.overlay.b_locked}
|
|
nodeId={id}
|
|
onWidgetChange={ctx.onWidgetChange}
|
|
/>
|
|
)}
|
|
</Suspense>
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{/* Collapsible table data */}
|
|
{data.tableRows && data.tableRows.length > 0 && (
|
|
<CollapsibleSection title="Table" defaultOpen={true}>
|
|
<div className="node-table">
|
|
{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 <div key={i} className="table-line">{line}</div>;
|
|
})}
|
|
</div>
|
|
</CollapsibleSection>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<>
|
|
<label>{name}</label>
|
|
<select
|
|
className="nodrag"
|
|
value={val || type[0]}
|
|
onChange={(e) => onChange(nodeId, name, e.target.value)}
|
|
>
|
|
{type.map((opt) => (
|
|
<option key={opt} value={opt}>{opt}</option>
|
|
))}
|
|
</select>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (type === 'FILE_PICKER') {
|
|
return (
|
|
<>
|
|
<label>{name}</label>
|
|
<div className="file-picker-row">
|
|
<input
|
|
className="nodrag"
|
|
type="text"
|
|
value={val}
|
|
onChange={(e) => onChange(nodeId, name, e.target.value)}
|
|
placeholder="Select file…"
|
|
/>
|
|
<button
|
|
className="nodrag browse-btn"
|
|
onClick={() => openFileBrowser((path) => onChange(nodeId, name, path))}
|
|
>
|
|
Browse
|
|
</button>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<label>{name}</label>
|
|
<div className="slider-control">
|
|
<input
|
|
className="nodrag slider-input"
|
|
type="range"
|
|
min={sliderMin}
|
|
max={sliderMax}
|
|
step={step}
|
|
value={clampedVal}
|
|
onChange={(e) => onChange(nodeId, name, parseFloat(e.target.value))}
|
|
/>
|
|
<span className="slider-value">{clampedVal.toFixed(4)}</span>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<label>{name}</label>
|
|
<DraggableNumber
|
|
value={val || 0}
|
|
step={opts?.step ?? 0.01}
|
|
min={opts?.min}
|
|
max={opts?.max}
|
|
precision={4}
|
|
onChange={(v) => onChange(nodeId, name, v)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (type === 'INT') {
|
|
return (
|
|
<>
|
|
<label>{name}</label>
|
|
<DraggableNumber
|
|
value={val || 0}
|
|
step={opts?.step ?? 1}
|
|
min={opts?.min}
|
|
max={opts?.max}
|
|
precision={0}
|
|
onChange={(v) => onChange(nodeId, name, v)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (type === 'BOOLEAN') {
|
|
return (
|
|
<>
|
|
<label>{name}</label>
|
|
<input
|
|
className="nodrag"
|
|
type="checkbox"
|
|
checked={!!val}
|
|
onChange={(e) => onChange(nodeId, name, e.target.checked)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// STRING and anything else
|
|
return (
|
|
<>
|
|
<label>{name}</label>
|
|
<input
|
|
className="nodrag"
|
|
type="text"
|
|
value={val}
|
|
onChange={(e) => onChange(nodeId, name, e.target.value)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default memo(CustomNode);
|