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

@@ -26,6 +26,7 @@ const TYPE_COLORS = {
LINE: '#ffbe5c',
TABLE: '#35e2fd',
COORD: '#e91ed1',
FLOAT: '#7dd3fc',
};
const NODE_TYPES = { custom: CustomNode };

View File

@@ -0,0 +1,88 @@
import React, { useRef, useState, useCallback } from 'react';
export default function CropBoxOverlay({
image, x1, y1, x2, y2,
aLocked, bLocked,
nodeId, onWidgetChange,
}) {
const containerRef = useRef(null);
const [dragging, setDragging] = useState(null);
const getCoords = useCallback((e) => {
const rect = containerRef.current.getBoundingClientRect();
return {
fx: Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)),
fy: Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)),
};
}, []);
const onPointerDown = useCallback((point) => (e) => {
if (point === 'p1' && aLocked) return;
if (point === 'p2' && bLocked) return;
e.stopPropagation();
e.preventDefault();
e.target.setPointerCapture(e.pointerId);
setDragging(point);
}, [aLocked, bLocked]);
const onPointerMove = useCallback((e) => {
if (!dragging || !containerRef.current) return;
const { fx, fy } = getCoords(e);
const vx = parseFloat(fx.toFixed(3));
const vy = parseFloat(fy.toFixed(3));
if (dragging === 'p1') {
onWidgetChange(nodeId, 'x1', vx);
onWidgetChange(nodeId, 'y1', vy);
} else {
onWidgetChange(nodeId, 'x2', vx);
onWidgetChange(nodeId, 'y2', vy);
}
}, [dragging, getCoords, nodeId, onWidgetChange]);
const onPointerUp = useCallback(() => {
setDragging(null);
}, []);
const left = Math.min(x1, x2);
const right = Math.max(x1, x2);
const top = Math.min(y1, y2);
const bottom = Math.max(y1, y2);
return (
<div
ref={containerRef}
className="nodrag nowheel crop-overlay"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onLostPointerCapture={onPointerUp}
>
<img src={image} alt="crop source" draggable={false} className="crop-image" />
<div className="crop-dim" style={{ left: 0, top: 0, width: '100%', height: `${top * 100}%` }} />
<div className="crop-dim" style={{ left: 0, top: `${top * 100}%`, width: `${left * 100}%`, height: `${(bottom - top) * 100}%` }} />
<div className="crop-dim" style={{ left: `${right * 100}%`, top: `${top * 100}%`, width: `${(1 - right) * 100}%`, height: `${(bottom - top) * 100}%` }} />
<div className="crop-dim" style={{ left: 0, top: `${bottom * 100}%`, width: '100%', height: `${(1 - bottom) * 100}%` }} />
<div
className="crop-rect"
style={{
left: `${left * 100}%`,
top: `${top * 100}%`,
width: `${(right - left) * 100}%`,
height: `${(bottom - top) * 100}%`,
}}
/>
<div
className={`crop-marker ${aLocked ? 'crop-marker-locked' : ''}`}
style={{ left: `${x1 * 100}%`, top: `${y1 * 100}%` }}
onPointerDown={onPointerDown('p1')}
/>
<div
className={`crop-marker ${bLocked ? 'crop-marker-locked' : ''}`}
style={{ left: `${x2 * 100}%`, top: `${y2 * 100}%` }}
onPointerDown={onPointerDown('p2')}
/>
</div>
);
}

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>

View File

@@ -196,6 +196,11 @@ html, body, #root {
display: flex;
align-items: center;
gap: 6px;
position: relative;
}
.widget-row-socket {
padding-left: 20px;
}
.widget-row label {
@@ -222,6 +227,28 @@ html, body, #root {
accent-color: #3a7abf;
}
.slider-control {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.slider-input {
flex: 1;
min-width: 0;
accent-color: #7dd3fc;
}
.slider-value {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 10px;
color: #cbd5e1;
min-width: 52px;
text-align: right;
}
.widget-row input:focus,
.widget-row select:focus {
outline: none;
@@ -402,6 +429,58 @@ html, body, #root {
cursor: default;
}
.crop-overlay {
position: relative;
user-select: none;
touch-action: none;
overflow: hidden;
}
.crop-image {
width: 100%;
display: block;
}
.crop-dim {
position: absolute;
background: rgba(2, 6, 23, 0.58);
pointer-events: none;
}
.crop-rect {
position: absolute;
border: 2px solid #7dd3fc;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22);
background: transparent;
pointer-events: none;
}
.crop-marker {
position: absolute;
width: 14px;
height: 14px;
border-radius: 50%;
background: #7dd3fc;
border: 2px solid #fff;
transform: translate(-50%, -50%);
cursor: grab;
box-shadow: 0 0 4px rgba(0,0,0,0.6);
z-index: 1;
}
.crop-marker:active:not(.crop-marker-locked) {
cursor: grabbing;
background: #bae6fd;
transform: translate(-50%, -50%) scale(1.15);
}
.crop-marker-locked {
background: #e91e63;
border-color: #e91e63;
cursor: default;
opacity: 0.9;
}
/* ── 3D surface view ──────────────────────────────────────────────── */
.surface-view-container {
width: 100%;