Browse Source

加入画布控件

master
zhangjiming 5 months ago
parent
commit
e3c4206325
  1. 2
      src/components/CustomNavBar.tsx
  2. 596
      src/components/konva/MeasurementCanvas.tsx
  3. 24
      src/pages/Measure.tsx

2
src/components/CustomNavBar.tsx

@ -10,7 +10,7 @@ export default function CustomNavBar({ title }: { title: string }) {
<div className="relative bg-white h-[--navBarHeight] border-b border-[#D8D8D8]"> <div className="relative bg-white h-[--navBarHeight] border-b border-[#D8D8D8]">
{/** 温度,水平仪 */} {/** 温度,水平仪 */}
<div <div
className="absolute h-[30px] w-full bg-white border-t border-[#D8D8D8] flex items-center gap-2 px-4"
className="absolute h-[30px] w-full bg-white border border-[#D8D8D8] flex items-center gap-2 px-4"
style={{ top: showDetail ? '100%' : 0, transition: 'top 300ms' }}> style={{ top: showDetail ? '100%' : 0, transition: 'top 300ms' }}>
<span className="flex-1">温度: 31.2°C</span> <span className="flex-1">温度: 31.2°C</span>
<span className="flex-1">X轴倾斜: -0.496</span> <span className="flex-1">X轴倾斜: -0.496</span>

596
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<MeasurementCanvasRef, MeasurementCanvasProps>(
(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<number>(initialScale);
const [benchmarkData, setBenchmarkData] = useState<BenchmarkShape[]>(initialBenchmarkData);
const [analysisData, setAnalysisData] = useState<AnalysisData[]>(initialAnalysisData);
// 定时更新测量数据(左右两侧)
const leftPointsRef = useRef<Point[]>([...initialMeasurementDataLeft]);
const rightPointsRef = useRef<Point[]>([...initialMeasurementDataRight]);
const [measurementDataLeft, setMeasurementDataLeftState] = useState<Point[]>(initialMeasurementDataLeft);
const [measurementDataRight, setMeasurementDataRightState] = useState<Point[]>(initialMeasurementDataRight);
const [measurementData, setMeasurementDataState] = useState<Point[]>([]);
const refreshInterval = 50;
const refreshTimer = useRef<number | null>(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<any>(null);
// 记录当前活跃 pointer(pointerId -> Point)
const pointersRef = useRef<Map<number, Point>>(new Map());
// 单指拖拽的上一次位置
const lastDragPosRef = useRef<Point | null>(null);
// 针对鼠标,记录是否按下
const isDraggingRef = useRef<boolean>(false);
// 记录双指缩放的初始数据(只在两指刚开始时记录一次)
const pinchDataRef = useRef<PinchData | null>(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(
<Line key={`v-${x}`} points={[p1.x, p1.y, p2.x, p2.y]} stroke="#eee" strokeWidth={1} />
);
}
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(
<Line key={`h-${y}`} points={[p1.x, p1.y, p2.x, p2.y]} stroke="#eee" strokeWidth={1} />
);
}
const xAxisStart = transform({ x: logicalExtent.minX, y: 0 });
const xAxisEnd = transform({ x: logicalExtent.maxX, y: 0 });
lines.push(
<Line key="x-axis" points={[xAxisStart.x, xAxisStart.y, xAxisEnd.x, xAxisEnd.y]} stroke="gray" strokeWidth={1} />
);
const yAxisStart = transform({ x: 0, y: logicalExtent.minY });
const yAxisEnd = transform({ x: 0, y: logicalExtent.maxY });
lines.push(
<Line key="y-axis" points={[yAxisStart.x, yAxisStart.y, yAxisEnd.x, yAxisEnd.y]} stroke="gray" strokeWidth={1} />
);
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(
<Text key={`coord-x-${x}`} x={pos.x - 10} y={height - 20} text={x.toFixed(0)} fontSize={12} fill="black" />
);
}
for (let y = logicalExtent.minY; y <= logicalExtent.maxY; y += dynamicYInterval) {
const pos = transform({ x: 0, y });
texts.push(
<Text key={`coord-y-${y}`} x={5} y={pos.y - 6} text={y.toFixed(0)} fontSize={12} fill="black" />
);
}
return texts;
};
const renderBenchmarkShapes = () => {
return benchmarkData.map((shape, idx) => {
if (shape.type === "line") {
const p1 = transform(shape.start);
const p2 = transform(shape.end);
return (
<Line
key={`benchmark-line-${idx}`}
points={[p1.x, p1.y, p2.x, p2.y]}
stroke={shape.color}
strokeWidth={1}
/>
);
} 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 (
<Shape
key={`benchmark-arc-${idx}`}
sceneFunc={(ctx, shapeObj) => {
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 (
<Line
points={pts}
stroke="red"
strokeWidth={2}
tension={1}
lineCap="round"
lineJoin="round"
/>
);
};
const renderMeasurementCurveRight = () => {
if (measurementDataRight.length === 0) return null;
const pts = measurementDataRight
.map((pt) => {
const p = transform(pt);
return [p.x, p.y];
})
.flat();
return (
<Line
points={pts}
stroke="red"
strokeWidth={2}
tension={1}
lineCap="round"
lineJoin="round"
/>
);
};
const renderMeasurementCurve = () => {
if (measurementData.length === 0) return null;
const pts = measurementData
.map((pt) => {
const p = transform(pt);
return [p.x, p.y];
})
.flat();
return (
<Line
points={pts}
stroke="purple"
strokeWidth={2}
tension={1}
lineCap="round"
lineJoin="round"
/>
);
};
const renderAnalysis = () => {
return analysisData.map((item, idx) => {
const pA = transform(item.pointA);
const pB = transform(item.pointB);
return (
<React.Fragment key={`analysis-${idx}`}>
<Line points={[pA.x, pA.y, pB.x, pB.y]} stroke="red" strokeWidth={1} />
<Text x={pA.x - 15} y={pA.y - 15} text={item.describe} fontSize={14} fill="black" />
</React.Fragment>
);
});
};
return (
<div
style={{
width,
height,
border: "1px solid #ccc",
position: "relative",
touchAction: "none",
}}
>
<Stage
ref={stageRef}
width={width}
height={height}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onWheel={handleWheel}
style={{ background: "#fff" }}
>
<Layer>
{showGrid && renderGridAndAxes()}
{showBenchmark && renderBenchmarkShapes()}
{renderMeasurementCurveLeft()}
{renderMeasurementCurveRight()}
{renderMeasurementCurve()}
{showAnalysis && renderAnalysis()}
</Layer>
{showCoordinates && <Layer>{renderCoordinates()}</Layer>}
</Stage>
{showScale && (
<div
style={{
position: "absolute",
bottom: 10,
left: 10,
background: "rgba(255,255,255,0.8)",
padding: "2px 4px",
border: "1px solid #ccc",
fontSize: 12,
pointerEvents: "none",
}}
>
<div
style={{
width: gridStep * scaleInterval * scale,
borderBottom: "2px solid black",
marginBottom: 2,
}}
></div>
<div>{`${gridStep * scaleInterval} mm`}</div>
</div>
)}
</div>
);
}
);
export default MeasurementCanvas;

24
src/pages/Measure.tsx

@ -1,11 +1,14 @@
import { NavBar } from 'antd-mobile';
import StepItem from '../components/StepItem'; import StepItem from '../components/StepItem';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import CustomNavBar from '../components/CustomNavBar'; import CustomNavBar from '../components/CustomNavBar';
import MeasurementCanvas, { MeasurementCanvasRef } from '../components/konva/MeasurementCanvas';
import { useRef } from 'react';
export default function Measure() { export default function Measure() {
const navigate = useNavigate(); const navigate = useNavigate();
const canvasRef = useRef<MeasurementCanvasRef>(null);
const onSaveClick = () => { const onSaveClick = () => {
navigate('/measure/save'); navigate('/measure/save');
}; };
@ -17,7 +20,24 @@ export default function Measure() {
<main className="home-page-content overflow-x-hidden overflow-y-auto"> <main className="home-page-content overflow-x-hidden overflow-y-auto">
<div className="relative h-0 p-0 pb-[75%]"> <div className="relative h-0 p-0 pb-[75%]">
<div className="absolute left-0 right-0 top-0 bottom-0 bg-title"></div>
<div className="absolute left-0 right-0 top-0 bottom-0 bg-title">
<MeasurementCanvas
width={window.innerWidth}
height={window.innerWidth * 0.75}
logicalExtent={{ minX: -50, maxX: 50, minY: -20, maxY: 60 }}
gridStep={1}
origin={{ x: 0, y: 20 }}
pixelPerMm={window.innerWidth / 100}
maxZoom={5}
showGrid={true}
showBenchmark={true}
showAnalysis={false}
showScale={false}
scaleInterval={1}
showCoordinates={false}
ref={canvasRef}
/>
</div>
</div> </div>
<section className="h-10 bg-[#e3e8f5] flex justify-between items-center px-4"> <section className="h-10 bg-[#e3e8f5] flex justify-between items-center px-4">

Loading…
Cancel
Save