add radial profile

This commit is contained in:
2026-04-15 23:01:47 -07:00
parent 1d98ccf190
commit 0bf001c24b
6 changed files with 246 additions and 14 deletions

View File

@@ -12,6 +12,7 @@ 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';
@@ -1194,6 +1195,7 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|| 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
@@ -1209,6 +1211,8 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
? 'Cursors'
: data.overlay?.kind === 'line_plot'
? 'Line Plot'
: data.overlay?.kind === 'radial_profile'
? 'Radial Profile'
: 'Cross Section');
const headerMeta = (() => {
if (data.className === 'Folder') {
@@ -1541,6 +1545,16 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
nodeId={id}
onWidgetChange={ctx!.onWidgetChange}
/>
) : data.overlay!.kind === 'radial_profile' ? (
<RadialProfileOverlay
image={data.overlay!.image ?? ''}
cx={(data.widgetValues.cx ?? data.overlay!.cx ?? 0.5) as number}
cy={(data.widgetValues.cy ?? data.overlay!.cy ?? 0.5) as number}
ex={(data.widgetValues.ex ?? data.overlay!.ex ?? 0.9) as number}
ey={(data.widgetValues.ey ?? data.overlay!.ey ?? 0.5) as number}
nodeId={id}
onWidgetChange={ctx!.onWidgetChange}
/>
) : data.overlay!.kind === 'angle_measure' ? (
<AngleMeasureOverlay
image={data.overlay!.image ?? ''}

View File

@@ -0,0 +1,125 @@
import React, { useRef, useState, useCallback } from 'react';
import { clampFraction, pointerToFraction } from './overlayUtils';
export const CAPTURE_SELECTOR = '.radial-overlay';
interface RadialProfileOverlayProps {
image: string;
cx: number;
cy: number;
ex: number;
ey: number;
nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
}
type DragHandle = 'center' | 'a' | 'b';
interface DragState {
handle: DragHandle;
start: { fx: number; fy: number };
points: { cx: number; cy: number; ex: number; ey: number };
}
const round3 = (v: number) => parseFloat(v.toFixed(3));
export default function RadialProfileOverlay({
image, cx, cy, ex, ey,
nodeId, onWidgetChange,
}: RadialProfileOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<DragState | null>(null);
const getCoords = useCallback((e: React.PointerEvent<Element>) => {
return pointerToFraction(e, containerRef.current!);
}, []);
const updateWidgets = useCallback((updates: Record<string, number>) => {
for (const [name, value] of Object.entries(updates)) {
onWidgetChange(nodeId, name, value);
}
}, [nodeId, onWidgetChange]);
const onPointerDown = useCallback((handle: DragHandle) => (e: React.PointerEvent<Element>) => {
e.stopPropagation();
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
const start = getCoords(e);
setDragging({ handle, start, points: { cx, cy, ex, ey } });
}, [cx, cy, ex, ey, getCoords]);
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
if (!dragging || !containerRef.current) return;
const { fx, fy } = getCoords(e);
const pts = dragging.points;
if (dragging.handle === 'center') {
const dx = fx - dragging.start.fx;
const dy = fy - dragging.start.fy;
updateWidgets({
cx: round3(clampFraction(pts.cx + dx)),
cy: round3(clampFraction(pts.cy + dy)),
ex: round3(clampFraction(pts.ex + dx)),
ey: round3(clampFraction(pts.ey + dy)),
});
} else if (dragging.handle === 'a') {
updateWidgets({ ex: round3(fx), ey: round3(fy) });
} else {
updateWidgets({
ex: round3(clampFraction(2 * pts.cx - fx)),
ey: round3(clampFraction(2 * pts.cy - fy)),
});
}
}, [dragging, getCoords, updateWidgets]);
const onPointerUp = useCallback(() => {
setDragging(null);
}, []);
const bx = 2 * cx - ex;
const by = 2 * cy - ey;
const rxFrac = Math.abs(ex - cx);
const ryFrac = Math.abs(ey - cy);
return (
<div
ref={containerRef}
className="nodrag nowheel radial-overlay"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onLostPointerCapture={onPointerUp}
>
<img src={image} alt="field" draggable={false} className="radial-image" />
<svg className="radial-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
<ellipse
cx={cx * 100} cy={cy * 100}
rx={rxFrac * 100} ry={ryFrac * 100}
className="radial-circle"
/>
<line
x1={ex * 100} y1={ey * 100}
x2={bx * 100} y2={by * 100}
className="radial-diameter"
/>
</svg>
<div
className="radial-marker radial-marker-end"
style={{ left: `${ex * 100}%`, top: `${ey * 100}%` }}
onPointerDown={onPointerDown('a')}
/>
<div
className="radial-marker radial-marker-end"
style={{ left: `${bx * 100}%`, top: `${by * 100}%` }}
onPointerDown={onPointerDown('b')}
/>
<div
className="radial-marker radial-marker-center"
style={{ left: `${cx * 100}%`, top: `${cy * 100}%` }}
onPointerDown={onPointerDown('center')}
/>
</div>
);
}

View File

@@ -1633,6 +1633,62 @@ html, body, #root {
opacity: 0.9;
}
/* ── Radial profile overlay ───────────────────────────────────────── */
.radial-overlay {
position: relative;
user-select: none;
touch-action: none;
overflow: hidden;
}
.radial-image {
width: 100%;
display: block;
}
.radial-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.radial-circle {
fill: none;
stroke: var(--marker);
stroke-width: 1.4;
vector-effect: non-scaling-stroke;
stroke-dasharray: 4 3;
}
.radial-diameter {
stroke: var(--marker);
stroke-width: 1.2;
vector-effect: non-scaling-stroke;
stroke-dasharray: 3 3;
opacity: 0.7;
}
.radial-marker {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--marker);
border: 1px solid var(--marker-border);
transform: translate(-50%, -50%);
cursor: grab;
box-shadow: 0 0 4px var(--marker-shadow);
z-index: 1;
}
.radial-marker:active {
cursor: grabbing;
background: var(--marker-active);
transform: translate(-50%, -50%) scale(1.2);
}
.radial-marker-center {
width: 10px;
height: 10px;
border-radius: 2px;
}
.angle-overlay {
--angle-line-color: #ff9800;
--angle-arc-color: rgb(255, 166, 77);
@@ -1879,7 +1935,8 @@ html, body, #root {
.is-panning .lineplot-overlay,
.is-panning .crop-overlay,
.is-panning .mask-paint-overlay,
.is-panning .markup-overlay {
.is-panning .markup-overlay,
.is-panning .radial-overlay {
pointer-events: none;
}

View File

@@ -66,6 +66,10 @@ export interface OverlayData {
y2?: number;
xm?: number;
ym?: number;
cx?: number;
cy?: number;
ex?: number;
ey?: number;
a_locked?: boolean;
b_locked?: boolean;
section_title?: string;

View File

@@ -11,6 +11,7 @@ export const OVERLAY_CAPTURE_SELECTORS = [
'.crop-overlay', // CropBoxOverlay
'.markup-overlay', // MarkupOverlay
'.angle-overlay', // AngleMeasureOverlay
'.radial-overlay', // RadialProfileOverlay
];
function encodeBase64(bytes: Uint8Array) {