From e3c42063259720d712e82b1ae26f70b03b77570a Mon Sep 17 00:00:00 2001 From: zhangjiming Date: Thu, 20 Mar 2025 19:27:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=85=A5=E7=94=BB=E5=B8=83=E6=8E=A7?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CustomNavBar.tsx | 2 +- src/components/konva/MeasurementCanvas.tsx | 596 +++++++++++++++++++++++++++++ src/pages/Measure.tsx | 24 +- 3 files changed, 619 insertions(+), 3 deletions(-) create mode 100644 src/components/konva/MeasurementCanvas.tsx diff --git a/src/components/CustomNavBar.tsx b/src/components/CustomNavBar.tsx index 6d3391b..da85266 100644 --- a/src/components/CustomNavBar.tsx +++ b/src/components/CustomNavBar.tsx @@ -10,7 +10,7 @@ export default function CustomNavBar({ title }: { title: string }) {
{/** 温度,水平仪 */}
温度: 31.2°C X轴倾斜: -0.496 diff --git a/src/components/konva/MeasurementCanvas.tsx b/src/components/konva/MeasurementCanvas.tsx new file mode 100644 index 0000000..0af7451 --- /dev/null +++ b/src/components/konva/MeasurementCanvas.tsx @@ -0,0 +1,596 @@ +import React, { + useState, + useRef, + useImperativeHandle, + forwardRef, + useEffect, +} from "react"; +import { Stage, Layer, Line, Shape, Text } from "react-konva"; + +// 数据类型定义 +export interface Point { + x: number; + y: number; +} + +export interface BenchmarkArc { + type: "arc"; + start: Point; + end: Point; + radius: number; + color: string; + side: "right" | "left" | "up" | "down"; +} + +export interface BenchmarkLine { + type: "line"; + start: Point; + end: Point; + color: string; +} + +export type BenchmarkShape = BenchmarkArc | BenchmarkLine; + +export interface AnalysisData { + pointA: Point; + pointB: Point; + describe: string; +} + +// 逻辑坐标范围(单位:毫米) +export interface LogicalExtent { + minX: number; + maxX: number; + minY: number; + maxY: number; +} + +export interface MeasurementCanvasProps { + width: number; + height: number; + logicalExtent?: LogicalExtent; + gridStep?: number; + showGrid?: boolean; + showScale?: boolean; + scaleInterval?: number; + showCoordinates?: boolean; + coordinateInterval?: number; + pixelPerMm?: number; + origin?: Point; + minZoom?: number; + maxZoom?: number; + initialBenchmarkData?: BenchmarkShape[]; + initialMeasurementDataLeft?: Point[]; + initialMeasurementDataRight?: Point[]; + initialAnalysisData?: AnalysisData[]; + // 控制是否显示标准线(benchmark shapes) + showBenchmark?: boolean; + // 控制是否显示分析线 + showAnalysis?: boolean; +} + +export interface MeasurementCanvasRef { + resetCanvas: () => void; + clearShapes: () => void; + setBenchmarkData: (data: BenchmarkShape[]) => void; + setMeasurementDataLeft: (data: Point[]) => void; + setMeasurementDataRight: (data: Point[]) => void; + setMeasurementData: (data: Point[]) => void; + setAnalysisData: (data: AnalysisData[]) => void; + redraw: () => void; +} + +interface PinchData { + initialDistance: number; + initialScale: number; + initialOffset: { x: number; y: number }; + initialCenter: Point; // 固定的缩放中心 +} + +const MeasurementCanvas = forwardRef( + (props, ref) => { + const { + width, + height, + logicalExtent = { minX: -100, maxX: 100, minY: -100, maxY: 100 }, + gridStep = 1, + showGrid = true, + showScale = false, + scaleInterval = 10, + showCoordinates = false, + coordinateInterval = 1, + pixelPerMm = 10, + origin = { x: 0, y: 0 }, + minZoom = 1, + maxZoom = 10, + initialBenchmarkData = [], + initialMeasurementDataLeft = [], + initialMeasurementDataRight = [], + initialAnalysisData = [], + showBenchmark = true, + showAnalysis = true, + } = props; + + // Stage 物理中心(像素) + const canvasCenter = { x: width / 2, y: height / 2 }; + + // 计算初始 scale 与 offset + const logicalWidth = logicalExtent.maxX - logicalExtent.minX; + const logicalHeight = logicalExtent.maxY - logicalExtent.minY; + const computedScale = Math.min(width / logicalWidth, height / logicalHeight); + const initialScale = computedScale > pixelPerMm ? computedScale : pixelPerMm; + const logicalCenter = { + x: (logicalExtent.minX + logicalExtent.maxX) / 2, + y: (logicalExtent.minY + logicalExtent.maxY) / 2, + }; + const initialOffset = + computedScale > pixelPerMm + ? { + x: -(logicalCenter.x - origin.x) * initialScale, + y: -(logicalCenter.y - origin.y) * initialScale, + } + : { x: 0, y: 0 }; + + const [offset, setOffset] = useState<{ x: number; y: number }>(initialOffset); + const [scale, setScale] = useState(initialScale); + const [benchmarkData, setBenchmarkData] = useState(initialBenchmarkData); + const [analysisData, setAnalysisData] = useState(initialAnalysisData); + + // 定时更新测量数据(左右两侧) + const leftPointsRef = useRef([...initialMeasurementDataLeft]); + const rightPointsRef = useRef([...initialMeasurementDataRight]); + const [measurementDataLeft, setMeasurementDataLeftState] = useState(initialMeasurementDataLeft); + const [measurementDataRight, setMeasurementDataRightState] = useState(initialMeasurementDataRight); + const [measurementData, setMeasurementDataState] = useState([]); + const refreshInterval = 50; + const refreshTimer = useRef(null); + useEffect(() => { + if (!refreshTimer.current) { + refreshTimer.current = window.setInterval(() => { + setMeasurementDataLeftState([...leftPointsRef.current]); + setMeasurementDataRightState([...rightPointsRef.current]); + }, refreshInterval); + } + return () => { + if (refreshTimer.current) { + clearInterval(refreshTimer.current); + refreshTimer.current = null; + } + }; + }, []); + + useImperativeHandle(ref, () => ({ + resetCanvas: () => { + setScale(pixelPerMm); + setOffset({ x: 0, y: 0 }); + }, + clearShapes: () => { + leftPointsRef.current = []; + rightPointsRef.current = []; + setMeasurementDataLeftState([]); + setMeasurementDataRightState([]); + setAnalysisData([]); + setMeasurementDataState([]); + }, + setBenchmarkData: (data: BenchmarkShape[]) => { + setBenchmarkData(data); + }, + setMeasurementDataLeft: (data: Point[]) => { + leftPointsRef.current = data; + }, + setMeasurementDataRight: (data: Point[]) => { + rightPointsRef.current = data; + }, + setMeasurementData: (data: Point[]) => { + setMeasurementDataState(data); + }, + setAnalysisData: (data: AnalysisData[]) => { + setAnalysisData(data); + }, + redraw: () => { + setScale((prev) => prev); + }, + })); + + const stageRef = useRef(null); + + // 记录当前活跃 pointer(pointerId -> Point) + const pointersRef = useRef>(new Map()); + // 单指拖拽的上一次位置 + const lastDragPosRef = useRef(null); + // 针对鼠标,记录是否按下 + const isDraggingRef = useRef(false); + // 记录双指缩放的初始数据(只在两指刚开始时记录一次) + const pinchDataRef = useRef(null); + + // 辅助函数:从事件中获取正确的指针位置 + const getPointerPosition = (e: any): Point => { + const pos = stageRef.current.getPointerPosition(); + return pos ? pos : { x: e.evt.clientX, y: e.evt.clientY }; + }; + + const handlePointerDown = (e: any) => { + const point = getPointerPosition(e); + const pointerId = e.evt.pointerId; + pointersRef.current.set(pointerId, point); + // 判断是否为触摸事件 + const isTouch = e.evt.touches && e.evt.touches.length > 0; + if (!isTouch) { + isDraggingRef.current = true; + } + if (pointersRef.current.size === 1) { + lastDragPosRef.current = point; + } + // 当检测到两个手指时,记录初始数据——固定缩放中心 + if (pointersRef.current.size === 2) { + const pts = Array.from(pointersRef.current.values()); + const initialCenter = { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 }; + const initialDistance = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y); + pinchDataRef.current = { + initialDistance, + initialScale: scale, + initialOffset: offset, + initialCenter, // 固定的缩放中心 + }; + } + }; + + const handlePointerMove = (e: any) => { + const isTouch = e.evt.touches && e.evt.touches.length > 0; + if (!isTouch && !isDraggingRef.current) { + return; + } + const pointerId = e.evt.pointerId; + const point = getPointerPosition(e); + pointersRef.current.set(pointerId, point); + if (pointersRef.current.size === 1) { + // 单指拖拽 + if (lastDragPosRef.current) { + const dx = point.x - lastDragPosRef.current.x; + const dy = point.y - lastDragPosRef.current.y; + setOffset((prev) => clampOffset({ x: prev.x + dx, y: prev.y + dy }, scale)); + } + lastDragPosRef.current = point; + } else if (pointersRef.current.size >= 2 && pinchDataRef.current) { + // 双指缩放:固定缩放中心,与鼠标滚轮缩放逻辑一致 + const pts = Array.from(pointersRef.current.values()); + const currentDistance = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y); + const { initialDistance, initialScale, initialOffset, initialCenter } = pinchDataRef.current; + const scaleFactor = currentDistance / initialDistance; + let newScale = initialScale * scaleFactor; + newScale = Math.max(minZoom * pixelPerMm, Math.min(newScale, maxZoom * pixelPerMm)); + // 计算逻辑坐标 L:与鼠标滚轮缩放公式一致,固定 pointer 为初始记录的 initialCenter + const L = { + x: origin.x + (initialCenter.x - (canvasCenter.x + initialOffset.x)) / initialScale, + y: origin.y + (initialCenter.y - (canvasCenter.y + initialOffset.y)) / initialScale, + }; + // 计算新的 offset + const newOffset = { + x: initialCenter.x - canvasCenter.x - (L.x - origin.x) * newScale, + y: initialCenter.y - canvasCenter.y - (L.y - origin.y) * newScale, + }; + setScale(newScale); + // 此处调用 clampOffset 确保缩放过程中不会超出边界 + setOffset(clampOffset(newOffset, newScale)); + } + }; + + const handlePointerUp = (e: any) => { + const pointerId = e.evt.pointerId; + pointersRef.current.delete(pointerId); + const isTouch = e.evt.touches && e.evt.touches.length > 0; + if (!isTouch) { + isDraggingRef.current = false; + } + // 当手指数不足2时重置双指缩放初始数据 + if (pointersRef.current.size < 2) { + pinchDataRef.current = null; + } + if (pointersRef.current.size === 1) { + const remaining = Array.from(pointersRef.current.values())[0]; + lastDragPosRef.current = remaining; + } else if (pointersRef.current.size === 0) { + lastDragPosRef.current = null; + } + }; + + const handlePointerCancel = (e: any) => { + handlePointerUp(e); + }; + + // 鼠标滚轮缩放(仅适用于 PC) + const handleWheel = (e: any) => { + e.evt.preventDefault(); + const oldScale = scale; + const pointer = stageRef.current.getPointerPosition(); + if (!pointer) return; + const L = { + x: origin.x + (pointer.x - (canvasCenter.x + offset.x)) / oldScale, + y: origin.y + (pointer.y - (canvasCenter.y + offset.y)) / oldScale, + }; + let newScale = e.evt.deltaY < 0 ? oldScale * 1.1 : oldScale / 1.1; + newScale = Math.max(minZoom * pixelPerMm, Math.min(newScale, maxZoom * pixelPerMm)); + const newOffset = { + x: pointer.x - canvasCenter.x - (L.x - origin.x) * newScale, + y: pointer.y - canvasCenter.y - (L.y - origin.y) * newScale, + }; + setScale(newScale); + setOffset(clampOffset(newOffset, newScale)); + }; + + // 辅助函数:将逻辑坐标转换为屏幕坐标 + const transform = (pt: Point | null): { x: number; y: number } => { + if (!pt) return { x: 0, y: 0 }; + return { + x: canvasCenter.x + offset.x + (pt.x - origin.x) * scale, + y: canvasCenter.y + offset.y + (pt.y - origin.y) * scale, + }; + }; + + const clampOffset = (newOffset: { x: number; y: number }, currentScale: number) => { + const left = canvasCenter.x + newOffset.x + (logicalExtent.minX - origin.x) * currentScale; + const right = canvasCenter.x + newOffset.x + (logicalExtent.maxX - origin.x) * currentScale; + const top = canvasCenter.y + newOffset.y + (logicalExtent.minY - origin.y) * currentScale; + const bottom = canvasCenter.y + newOffset.y + (logicalExtent.maxY - origin.y) * currentScale; + let clampedX = newOffset.x; + let clampedY = newOffset.y; + if (left > 0) clampedX -= left; + if (right < width) clampedX += width - right; + if (top > 0) clampedY -= top; + if (bottom < height) clampedY += height - bottom; + return { x: clampedX, y: clampedY }; + }; + + const renderGridAndAxes = () => { + const lines = []; + for (let x = logicalExtent.minX; x <= logicalExtent.maxX; x += gridStep) { + const p1 = transform({ x, y: logicalExtent.minY }); + const p2 = transform({ x, y: logicalExtent.maxY }); + lines.push( + + ); + } + for (let y = logicalExtent.minY; y <= logicalExtent.maxY; y += gridStep) { + const p1 = transform({ x: logicalExtent.minX, y }); + const p2 = transform({ x: logicalExtent.maxX, y }); + lines.push( + + ); + } + const xAxisStart = transform({ x: logicalExtent.minX, y: 0 }); + const xAxisEnd = transform({ x: logicalExtent.maxX, y: 0 }); + lines.push( + + ); + const yAxisStart = transform({ x: 0, y: logicalExtent.minY }); + const yAxisEnd = transform({ x: 0, y: logicalExtent.maxY }); + lines.push( + + ); + return lines; + }; + + const renderCoordinates = () => { + const texts = []; + const minSpacing = 30; + const dynamicXInterval = Math.max(coordinateInterval, Math.ceil(minSpacing / scale)); + const dynamicYInterval = Math.max(coordinateInterval, Math.ceil(minSpacing / scale)); + for (let x = logicalExtent.minX; x <= logicalExtent.maxX; x += dynamicXInterval) { + const pos = transform({ x, y: 0 }); + texts.push( + + ); + } + for (let y = logicalExtent.minY; y <= logicalExtent.maxY; y += dynamicYInterval) { + const pos = transform({ x: 0, y }); + texts.push( + + ); + } + return texts; + }; + + const renderBenchmarkShapes = () => { + return benchmarkData.map((shape, idx) => { + if (shape.type === "line") { + const p1 = transform(shape.start); + const p2 = transform(shape.end); + return ( + + ); + } else if (shape.type === "arc") { + const dx = shape.end.x - shape.start.x; + const dy = shape.end.y - shape.start.y; + const d = Math.hypot(dx, dy); + const Mx = (shape.start.x + shape.end.x) / 2; + const My = (shape.start.y + shape.end.y) / 2; + const halfChord = d / 2; + const h = Math.sqrt(shape.radius * shape.radius - halfChord * halfChord); + const candidate1 = { + cx: Mx - (dy / d) * h, + cy: My + (dx / d) * h, + }; + const candidate2 = { + cx: Mx + (dy / d) * h, + cy: My - (dx / d) * h, + }; + const chosen = shape.side === "right" ? candidate2 : candidate1; + const startAngle = Math.atan2(shape.start.y - chosen.cy, shape.start.x - chosen.cx); + const endAngle = Math.atan2(shape.end.y - chosen.cy, shape.end.x - chosen.cx); + const normalize = (angle: number): number => { + while (angle < 0) angle += 2 * Math.PI; + while (angle >= 2 * Math.PI) angle -= 2 * Math.PI; + return angle; + }; + const params = { + cx: chosen.cx, + cy: chosen.cy, + r: shape.radius, + startAngle: normalize(startAngle), + endAngle: normalize(endAngle), + anticlockwise: + (normalize(endAngle) - normalize(startAngle) + 2 * Math.PI) % (2 * Math.PI) > Math.PI, + }; + return ( + { + ctx.beginPath(); + ctx.arc( + canvasCenter.x + offset.x + (params.cx - origin.x) * scale, + canvasCenter.y + offset.y + (params.cy - origin.y) * scale, + params.r * scale, + params.startAngle, + params.endAngle, + params.anticlockwise + ); + ctx.strokeStyle = shape.color; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.fillStrokeShape(shapeObj); + }} + /> + ); + } + return null; + }); + }; + + const renderMeasurementCurveLeft = () => { + if (measurementDataLeft.length === 0) return null; + const pts = measurementDataLeft + .map((pt) => { + const p = transform(pt); + return [p.x, p.y]; + }) + .flat(); + return ( + + ); + }; + + const renderMeasurementCurveRight = () => { + if (measurementDataRight.length === 0) return null; + const pts = measurementDataRight + .map((pt) => { + const p = transform(pt); + return [p.x, p.y]; + }) + .flat(); + return ( + + ); + }; + + const renderMeasurementCurve = () => { + if (measurementData.length === 0) return null; + const pts = measurementData + .map((pt) => { + const p = transform(pt); + return [p.x, p.y]; + }) + .flat(); + return ( + + ); + }; + + const renderAnalysis = () => { + return analysisData.map((item, idx) => { + const pA = transform(item.pointA); + const pB = transform(item.pointB); + return ( + + + + + ); + }); + }; + + return ( +
+ + + {showGrid && renderGridAndAxes()} + {showBenchmark && renderBenchmarkShapes()} + {renderMeasurementCurveLeft()} + {renderMeasurementCurveRight()} + {renderMeasurementCurve()} + {showAnalysis && renderAnalysis()} + + {showCoordinates && {renderCoordinates()}} + + {showScale && ( +
+
+
{`${gridStep * scaleInterval} mm`}
+
+ )} +
+ ); + } +); + +export default MeasurementCanvas; diff --git a/src/pages/Measure.tsx b/src/pages/Measure.tsx index 2a16dfe..59fe79b 100644 --- a/src/pages/Measure.tsx +++ b/src/pages/Measure.tsx @@ -1,11 +1,14 @@ -import { NavBar } from 'antd-mobile'; import StepItem from '../components/StepItem'; import { Link, useNavigate } from 'react-router-dom'; import CustomNavBar from '../components/CustomNavBar'; +import MeasurementCanvas, { MeasurementCanvasRef } from '../components/konva/MeasurementCanvas'; +import { useRef } from 'react'; export default function Measure() { const navigate = useNavigate(); + const canvasRef = useRef(null); + const onSaveClick = () => { navigate('/measure/save'); }; @@ -17,7 +20,24 @@ export default function Measure() {
-
+
+ +