add rotate, crop and slider widget

This commit is contained in:
2026-03-24 23:19:41 -07:00
parent 6959c62c8f
commit edfdead4c1
9 changed files with 717 additions and 8 deletions

View File

@@ -4,10 +4,12 @@ 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',
@@ -15,11 +17,13 @@ const TYPE_COLORS = {
LINE: '#ff9800',
TABLE: '#fdd835',
COORD: '#e91e63',
FLOAT: '#7dd3fc',
};
const CAT_COLORS = {
io: '#37474f',
filters: '#1a237e',
modify: '#0f766e',
level: '#1b5e20',
analysis: '#4a148c',
grains: '#bf360c',
@@ -189,7 +193,7 @@ function CustomNode({ id, data }) {
} else if (opts?.hidden) {
hiddenWidgets.add(name);
} else {
widgets.push({ name, type, opts: opts || {} });
widgets.push({ name, type, opts: opts || {}, socketType: SOCKET_WIDGET_TYPES.has(type) ? type : null });
}
}
@@ -214,7 +218,7 @@ function CustomNode({ id, data }) {
);
for (const [name, spec] of Object.entries(optional)) {
const [type] = Array.isArray(spec) ? spec : [spec];
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+)$/);
@@ -226,7 +230,13 @@ function CustomNode({ id, data }) {
continue;
}
}
dataInputs.push({ name, type });
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) => ({
@@ -291,11 +301,21 @@ function CustomNode({ id, data }) {
{/* Widget rows */}
{widgets.map((w) => (
<div className="widget-row" key={w.name}>
<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}
/>
@@ -347,7 +367,7 @@ function CustomNode({ id, data }) {
{/* Interactive cross-section overlay */}
{data.overlay && hiddenWidgets.has('x1') && (
<CollapsibleSection title="Cross Section" defaultOpen={true}>
<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
@@ -359,6 +379,18 @@ function CustomNode({ id, data }) {
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}
@@ -403,7 +435,7 @@ function CustomNode({ id, data }) {
// ── Widget renderer ───────────────────────────────────────────────────
function WidgetControl({ widget, nodeId, value, onChange, openFileBrowser }) {
function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFileBrowser }) {
const { name, type, opts } = widget;
const val = value ?? opts?.default ?? '';
@@ -449,6 +481,39 @@ function WidgetControl({ widget, nodeId, value, onChange, openFileBrowser }) {
}
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>