angle node kind of working
This commit is contained in:
214
frontend/src/AngleMeasureOverlay.jsx
Normal file
214
frontend/src/AngleMeasureOverlay.jsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
|
||||
import {
|
||||
getAngleLabelBasePosition,
|
||||
getAngleLabelPosition,
|
||||
measureAngleDegrees,
|
||||
moveAngleWidget,
|
||||
round3,
|
||||
} from './angleMeasureGeometry.js';
|
||||
|
||||
function clamp01(value) {
|
||||
return Math.max(0, Math.min(1, Number(value) || 0));
|
||||
}
|
||||
|
||||
function sanitizeHexColor(value, fallback = '#ff0000') {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
const text = value.trim();
|
||||
return /^#[0-9a-fA-F]{6}$/.test(text) ? text.toLowerCase() : fallback;
|
||||
}
|
||||
|
||||
function hexToRgb(value) {
|
||||
const color = sanitizeHexColor(value);
|
||||
return {
|
||||
r: parseInt(color.slice(1, 3), 16),
|
||||
g: parseInt(color.slice(3, 5), 16),
|
||||
b: parseInt(color.slice(5, 7), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function mixColor(baseColor, mixWith, weight) {
|
||||
const alpha = Math.max(0, Math.min(1, Number(weight) || 0));
|
||||
const base = hexToRgb(baseColor);
|
||||
const target = hexToRgb(mixWith);
|
||||
const r = Math.round((base.r * (1 - alpha)) + (target.r * alpha));
|
||||
const g = Math.round((base.g * (1 - alpha)) + (target.g * alpha));
|
||||
const b = Math.round((base.b * (1 - alpha)) + (target.b * alpha));
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
function formatAngle(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return '0.0 deg';
|
||||
return `${numeric.toFixed(1)} deg`;
|
||||
}
|
||||
|
||||
function buildAngleArcPath(x1, y1, xm, ym, x2, y2) {
|
||||
const va = { x: x1 - xm, y: y1 - ym };
|
||||
const vb = { x: x2 - xm, y: y2 - ym };
|
||||
const lenA = Math.hypot(va.x, va.y);
|
||||
const lenB = Math.hypot(vb.x, vb.y);
|
||||
if (lenA <= 1e-6 || lenB <= 1e-6) return '';
|
||||
|
||||
const radius = Math.min(0.12, 0.38 * Math.min(lenA, lenB));
|
||||
const start = { x: xm + (va.x / lenA) * radius, y: ym + (va.y / lenA) * radius };
|
||||
const end = { x: xm + (vb.x / lenB) * radius, y: ym + (vb.y / lenB) * radius };
|
||||
const cross = (va.x * vb.y) - (va.y * vb.x);
|
||||
|
||||
return [
|
||||
`M ${start.x * 100} ${start.y * 100}`,
|
||||
`A ${radius * 100} ${radius * 100} 0 0 ${cross >= 0 ? 1 : 0} ${end.x * 100} ${end.y * 100}`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
export default function AngleMeasureOverlay({
|
||||
image,
|
||||
x1,
|
||||
y1,
|
||||
xm,
|
||||
ym,
|
||||
x2,
|
||||
y2,
|
||||
labelDx,
|
||||
labelDy,
|
||||
angleDeg,
|
||||
color,
|
||||
lineThickness,
|
||||
nodeId,
|
||||
onWidgetChange,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const [dragging, setDragging] = useState(null);
|
||||
const resolvedColor = sanitizeHexColor(color, '#ff0000');
|
||||
const resolvedLineThickness = Math.max(0.35, Math.min(6, Number(lineThickness) || 1.35));
|
||||
const resolvedArcThickness = Math.max(0.85, resolvedLineThickness * 0.78);
|
||||
const resolvedArcColor = mixColor(resolvedColor, '#ffffff', 0.42);
|
||||
const resolvedMidColor = mixColor(resolvedColor, '#ffffff', 0.72);
|
||||
const resolvedBadgeTextColor = mixColor(resolvedColor, '#ffffff', 0.72);
|
||||
const resolvedBadgeBorderColor = mixColor(resolvedColor, '#ffffff', 0.32);
|
||||
|
||||
const getCoords = useCallback((event) => {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
return {
|
||||
fx: clamp01((event.clientX - rect.left) / rect.width),
|
||||
fy: clamp01((event.clientY - rect.top) / rect.height),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateWidgets = useCallback((updates) => {
|
||||
Object.entries(updates).forEach(([name, value]) => {
|
||||
onWidgetChange(nodeId, name, value);
|
||||
});
|
||||
}, [nodeId, onWidgetChange]);
|
||||
|
||||
const onPointerDown = useCallback((handle) => (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
|
||||
if (handle === 'mid') {
|
||||
const start = getCoords(event);
|
||||
setDragging({
|
||||
handle,
|
||||
start,
|
||||
points: { x1, y1, xm, ym, x2, y2 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setDragging({ handle });
|
||||
}, [getCoords, x1, y1, xm, ym, x2, y2]);
|
||||
|
||||
const onPointerMove = useCallback((event) => {
|
||||
if (!dragging || !containerRef.current) return;
|
||||
const { fx, fy } = getCoords(event);
|
||||
|
||||
if (dragging.handle === 'mid') {
|
||||
updateWidgets(moveAngleWidget(
|
||||
dragging.points,
|
||||
fx - dragging.start.fx,
|
||||
fy - dragging.start.fy,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragging.handle === 'p1') {
|
||||
updateWidgets({ x1: round3(fx), y1: round3(fy) });
|
||||
} else if (dragging.handle === 'p2') {
|
||||
updateWidgets({ x2: round3(fx), y2: round3(fy) });
|
||||
} else if (dragging.handle === 'label') {
|
||||
const base = getAngleLabelBasePosition(x1, y1, xm, ym, x2, y2);
|
||||
updateWidgets({
|
||||
label_dx: round3(fx - base.x),
|
||||
label_dy: round3(fy - base.y),
|
||||
});
|
||||
}
|
||||
}, [dragging, getCoords, updateWidgets, x1, y1, xm, ym, x2, y2]);
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
setDragging(null);
|
||||
}, []);
|
||||
|
||||
const displayedAngle = Number.isFinite(Number(angleDeg))
|
||||
? Number(angleDeg)
|
||||
: measureAngleDegrees(x1, y1, xm, ym, x2, y2);
|
||||
const arcPath = buildAngleArcPath(x1, y1, xm, ym, x2, y2);
|
||||
const labelPosition = getAngleLabelPosition({ x1, y1, xm, ym, x2, y2 }, labelDx, labelDy);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="nodrag nowheel angle-overlay"
|
||||
style={{
|
||||
'--angle-line-color': resolvedColor,
|
||||
'--angle-arc-color': resolvedArcColor,
|
||||
'--angle-end-handle-color': resolvedColor,
|
||||
'--angle-mid-handle-color': resolvedMidColor,
|
||||
'--angle-badge-text-color': resolvedBadgeTextColor,
|
||||
'--angle-badge-border-color': resolvedBadgeBorderColor,
|
||||
'--angle-line-thickness': `${resolvedLineThickness}`,
|
||||
'--angle-arc-thickness': `${resolvedArcThickness}`,
|
||||
}}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onLostPointerCapture={onPointerUp}
|
||||
>
|
||||
<img src={image} alt="angle source" draggable={false} className="angle-image" />
|
||||
<svg className="angle-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<line className="angle-line" x1={x1 * 100} y1={y1 * 100} x2={xm * 100} y2={ym * 100} />
|
||||
<line className="angle-line" x1={xm * 100} y1={ym * 100} x2={x2 * 100} y2={y2 * 100} />
|
||||
{arcPath && <path className="angle-arc" d={arcPath} />}
|
||||
</svg>
|
||||
|
||||
<div
|
||||
className="angle-badge"
|
||||
style={{ left: `${labelPosition.x * 100}%`, top: `${labelPosition.y * 100}%` }}
|
||||
onPointerDown={onPointerDown('label')}
|
||||
>
|
||||
{formatAngle(displayedAngle)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="angle-handle angle-handle-end"
|
||||
style={{ left: `${x1 * 100}%`, top: `${y1 * 100}%` }}
|
||||
onPointerDown={onPointerDown('p1')}
|
||||
>
|
||||
A
|
||||
</div>
|
||||
<div
|
||||
className="angle-handle angle-handle-mid"
|
||||
style={{ left: `${xm * 100}%`, top: `${ym * 100}%` }}
|
||||
onPointerDown={onPointerDown('mid')}
|
||||
>
|
||||
V
|
||||
</div>
|
||||
<div
|
||||
className="angle-handle angle-handle-end"
|
||||
style={{ left: `${x2 * 100}%`, top: `${y2 * 100}%` }}
|
||||
onPointerDown={onPointerDown('p2')}
|
||||
>
|
||||
B
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ const CrossSectionOverlay = lazy(() => import('./CrossSectionOverlay'));
|
||||
const CropBoxOverlay = lazy(() => import('./CropBoxOverlay'));
|
||||
const MaskPaintOverlay = lazy(() => import('./MaskPaintOverlay'));
|
||||
const MarkupOverlay = lazy(() => import('./MarkupOverlay'));
|
||||
const AngleMeasureOverlay = lazy(() => import('./AngleMeasureOverlay'));
|
||||
|
||||
import {
|
||||
DATA_TYPES, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS,
|
||||
@@ -1018,6 +1019,8 @@ function CustomNode({ id, data }) {
|
||||
? 'Mask'
|
||||
: data.overlay?.kind === 'markup'
|
||||
? 'Markup'
|
||||
: data.overlay?.kind === 'angle_measure'
|
||||
? 'Angle'
|
||||
: data.overlay?.kind === 'crop_box'
|
||||
? 'Crop'
|
||||
: data.overlay?.kind === 'cursor_points'
|
||||
@@ -1301,6 +1304,25 @@ function CustomNode({ id, data }) {
|
||||
nodeId={id}
|
||||
onWidgetChange={ctx.onWidgetChange}
|
||||
/>
|
||||
) : data.overlay.kind === 'angle_measure' ? (
|
||||
<AngleMeasureOverlay
|
||||
image={data.overlay.image}
|
||||
x1={data.widgetValues.x1 ?? data.overlay.x1}
|
||||
y1={data.widgetValues.y1 ?? data.overlay.y1}
|
||||
xm={data.widgetValues.xm ?? data.overlay.xm}
|
||||
ym={data.widgetValues.ym ?? data.overlay.ym}
|
||||
x2={data.widgetValues.x2 ?? data.overlay.x2}
|
||||
y2={data.widgetValues.y2 ?? data.overlay.y2}
|
||||
labelDx={data.widgetValues.label_dx ?? data.overlay.label_dx ?? 0}
|
||||
labelDy={data.widgetValues.label_dy ?? data.overlay.label_dy ?? 0}
|
||||
angleDeg={data.overlay.angle_deg}
|
||||
color={data.widgetValues.color ?? data.overlay.color ?? '#ff0000'}
|
||||
lineThickness={connectedInputs?.has('line_thickness_input')
|
||||
? (data.overlay.line_thickness ?? data.widgetValues.line_thickness ?? 1.35)
|
||||
: (data.widgetValues.line_thickness ?? data.overlay.line_thickness ?? 1.35)}
|
||||
nodeId={id}
|
||||
onWidgetChange={ctx.onWidgetChange}
|
||||
/>
|
||||
) : (
|
||||
<CrossSectionOverlay
|
||||
image={data.overlay.image}
|
||||
|
||||
77
frontend/src/angleMeasureGeometry.js
Normal file
77
frontend/src/angleMeasureGeometry.js
Normal file
@@ -0,0 +1,77 @@
|
||||
function clamp01(value) {
|
||||
return Math.max(0, Math.min(1, Number(value) || 0));
|
||||
}
|
||||
|
||||
export function round3(value) {
|
||||
return Number.parseFloat(Number(value).toFixed(3));
|
||||
}
|
||||
|
||||
export function getAngleLabelBasePosition(x1, y1, xm, ym, x2, y2) {
|
||||
const va = { x: Number(x1) - Number(xm), y: Number(y1) - Number(ym) };
|
||||
const vb = { x: Number(x2) - Number(xm), y: Number(y2) - Number(ym) };
|
||||
const lenA = Math.hypot(va.x, va.y);
|
||||
const lenB = Math.hypot(vb.x, vb.y);
|
||||
|
||||
if (lenA <= 1e-6 || lenB <= 1e-6) {
|
||||
return { x: clamp01(xm), y: clamp01(Number(ym) - 0.14) };
|
||||
}
|
||||
|
||||
const unit = {
|
||||
x: (va.x / lenA) + (vb.x / lenB),
|
||||
y: (va.y / lenA) + (vb.y / lenB),
|
||||
};
|
||||
const unitLength = Math.hypot(unit.x, unit.y);
|
||||
const bisector = unitLength <= 1e-6
|
||||
? { x: 0, y: -1 }
|
||||
: { x: unit.x / unitLength, y: unit.y / unitLength };
|
||||
|
||||
return {
|
||||
x: clamp01(Number(xm) + bisector.x * 0.14),
|
||||
y: clamp01(Number(ym) + bisector.y * 0.14),
|
||||
};
|
||||
}
|
||||
|
||||
export function getAngleLabelPosition(points, labelDx = 0, labelDy = 0) {
|
||||
const base = getAngleLabelBasePosition(points.x1, points.y1, points.xm, points.ym, points.x2, points.y2);
|
||||
return {
|
||||
x: clamp01(base.x + (Number(labelDx) || 0)),
|
||||
y: clamp01(base.y + (Number(labelDy) || 0)),
|
||||
};
|
||||
}
|
||||
|
||||
export function moveAngleWidget(points, dx, dy) {
|
||||
const nextDx = Number(dx) || 0;
|
||||
const nextDy = Number(dy) || 0;
|
||||
const xs = [points.x1, points.xm, points.x2];
|
||||
const ys = [points.y1, points.ym, points.y2];
|
||||
const minX = Math.min(...xs);
|
||||
const maxX = Math.max(...xs);
|
||||
const minY = Math.min(...ys);
|
||||
const maxY = Math.max(...ys);
|
||||
const clampedDx = Math.max(-minX, Math.min(1 - maxX, nextDx));
|
||||
const clampedDy = Math.max(-minY, Math.min(1 - maxY, nextDy));
|
||||
|
||||
return {
|
||||
x1: round3(clamp01(points.x1 + clampedDx)),
|
||||
y1: round3(clamp01(points.y1 + clampedDy)),
|
||||
xm: round3(clamp01(points.xm + clampedDx)),
|
||||
ym: round3(clamp01(points.ym + clampedDy)),
|
||||
x2: round3(clamp01(points.x2 + clampedDx)),
|
||||
y2: round3(clamp01(points.y2 + clampedDy)),
|
||||
};
|
||||
}
|
||||
|
||||
export function measureAngleDegrees(x1, y1, xm, ym, x2, y2) {
|
||||
const ax = Number(x1) - Number(xm);
|
||||
const ay = Number(y1) - Number(ym);
|
||||
const bx = Number(x2) - Number(xm);
|
||||
const by = Number(y2) - Number(ym);
|
||||
const lenA = Math.hypot(ax, ay);
|
||||
const lenB = Math.hypot(bx, by);
|
||||
|
||||
if (lenA <= 1e-12 || lenB <= 1e-12) return 0;
|
||||
|
||||
const cosTheta = ((ax * bx) + (ay * by)) / (lenA * lenB);
|
||||
const clamped = Math.max(-1, Math.min(1, cosTheta));
|
||||
return Math.acos(clamped) * (180 / Math.PI);
|
||||
}
|
||||
@@ -932,6 +932,105 @@ html, body, #root {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.angle-overlay {
|
||||
--angle-line-color: #ff0000;
|
||||
--angle-arc-color: rgb(255, 107, 107);
|
||||
--angle-end-handle-color: #ff0000;
|
||||
--angle-mid-handle-color: rgb(255, 184, 184);
|
||||
--angle-badge-text-color: rgb(255, 184, 184);
|
||||
--angle-badge-border-color: rgb(255, 82, 82);
|
||||
--angle-line-thickness: 1.35;
|
||||
--angle-arc-thickness: 1.05;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.angle-image {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.angle-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.angle-line {
|
||||
stroke: var(--angle-line-color);
|
||||
stroke-width: var(--angle-line-thickness);
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.angle-arc {
|
||||
fill: none;
|
||||
stroke: var(--angle-arc-color);
|
||||
stroke-width: var(--angle-arc-thickness);
|
||||
stroke-dasharray: 5 3;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.angle-handle {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: var(--bg-deep);
|
||||
cursor: grab;
|
||||
box-shadow: 0 0 6px var(--marker-shadow);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.angle-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.angle-handle-end {
|
||||
background: var(--angle-end-handle-color);
|
||||
border: 1px solid var(--marker-border);
|
||||
}
|
||||
|
||||
.angle-handle-mid {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--angle-mid-handle-color);
|
||||
border: 2px solid var(--marker-border);
|
||||
}
|
||||
|
||||
.angle-badge {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
border: 1px solid var(--angle-badge-border-color);
|
||||
color: var(--angle-badge-text-color);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.35);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.angle-badge:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.lineplot-overlay {
|
||||
width: 100%;
|
||||
aspect-ratio: 32 / 22;
|
||||
|
||||
Reference in New Issue
Block a user