add rotate, crop and slider widget
This commit is contained in:
@@ -26,6 +26,7 @@ const TYPE_COLORS = {
|
||||
LINE: '#ffbe5c',
|
||||
TABLE: '#35e2fd',
|
||||
COORD: '#e91ed1',
|
||||
FLOAT: '#7dd3fc',
|
||||
};
|
||||
|
||||
const NODE_TYPES = { custom: CustomNode };
|
||||
|
||||
88
frontend/src/CropBoxOverlay.jsx
Normal file
88
frontend/src/CropBoxOverlay.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user