背景
项目上需要使用一些绘制图形的交互,用于给摄像机添加标定,当行人或者车辆入侵时,自动出触发报警。
同时在仓库地图上,动态标定相机位置。实时查看相机状态。
需求
- 视频流添加标定工具:矩形与多边形。
- 仓库地图编辑,增加相机位置绑定。
效果
技术栈
项目:react@18.2.0
,turbo@1.9.8
,antd@5.XX
, pro-component
;
插件:"konva": "^9.0.1"
,"react-konva": "^18.2.7"
, "react-konva-utils": "^1.0.4"
;
此处介绍项目框架只是为什么更好的在讲解过程中,梳理结构与思路,插件部分的使用版本不同,api也会有所不同,所以,建议根据自己所有版本,查看api。
项目采用turbo + npmprepo的方式开发,简单介绍下项目结构,便于下文理解。
...
├─package.json
├─pnpm-workspace.yaml
├─turbo.json
├─packages
| ├─ui
| | ├─.eslintrc.js
| | ├─index.ts
| | ├─package.json
| | ├─tsconfig.json
| | ├─components
| | | └konva
| | | ├─Rect.tsx
| | | ├─Polygon.tsx
| | | └Stage.tsx
| ...
├─apps
...
| ├─admin
| | ├─.eslintrc.js
| | ├─index.html
| | ├─package.json
| | ├─tsconfig.json
| | ├─vite.config.ts
| | ├─src
| | | ...
| | | ├─pages
| | | | ...
| | | | ├─dashboard
| | | | | ├─index.tsx
| | | | | └style.module.scss
...
将业务系统与组件拆分,使组件开发变得更纯粹和快乐。
结构与真实项目并不一致,此处只是为了讲解做了删减。
绘制矩形与多边形
Stage场景组件
在packages => ui =>components
中开发组件,所有涉及到konva
绘制的依赖,我们都只会在ui的模块中添加。
下面,我们就来实现一个场景组件的开发,包含功能:
- 组件入参定义
- stage初始化
- Rect组件开发
- polygon组件开发以及回调用的实现。
创建一个
Stage.tsx
组件
import Konva from 'konva';
import React, { useEffect, useImperativeHandle, useRef, useState } from 'react';
import { Stage, Layer, Rect, Image } from 'react-konva';
import KonvaRect from './konva/Rect';
import KonvaPolygon from './konva/PolygonLine';
interface KonvaStageProps {
width: number;
height: number;
children?: React.ReactNode;
imgSrc?: string;
// Promise函数 drawEndFn
drawEndFn?: (data: Record<string, any>) => Promise<boolean>;
// 初始矩形数据
initialRectData?: Record<string, any>[];
}
const KonvaStage = (props: KonvaStageProps, ref) => {
const {
width = 500,
height = 500,
children,
imgSrc,
drawEndFn = () => Promise.resolve(true),
initialRectData = [],
} = props;
const stageRef = useRef<Konva.Stage>(null);
const [SinglePoint, setSinglePoint] = useState();
const [drawRact, setdrawRact] = useState(false);
const [editable, seteditable] = useState(false);
// 画多边形的状态管理
const [drawPolygon, setdrawPolygon] = useState(false);
const [polygonEditable, setPolygonEditable] = useState(false);
// 处理加载图片
const [loadImage, setloadImage] = useState(null);
const [ImgeSize, setImgeSize] = useState<Record<string, any>>({});
const drawFn = () => {
setdrawRact(true);
seteditable(false);
setdrawPolygon(false);
setPolygonEditable(false);
};
const editFn = () => {
setdrawRact(false);
seteditable(true);
};
const drawPolygonFn = () => {
setdrawRact(false);
setdrawPolygon(true);
setPolygonEditable(false);
};
const editPolygonFn = () => {
setPolygonEditable(true);
setdrawPolygon(false);
};
/**
* 删除单个Rect
* @param name 矩形名称
*/
const deleteRect = (name: string) => {
const stage = stageRef.current;
console.log(stage?.getStage().getChildren());
const layer = stage?.getStage().findOne('.rect-layer');
const rect = layer?.findOne(`.${name}`);
rect?.destroy();
stage?.getStage().draw();
};
/**
* 删除名为name的图形组件
* @param name 图形名称
* @param callback 回调函数
*/
const deleteOneShape = (
name: string,
callback: (data: Record<string, any>) => Promise<boolean>
) => {
return new Promise((resolve, reject) => {
const stage = stageRef.current;
const nodes = stage?.getStage().find('Rect, Line');
nodes?.filter((node) => {
if (node.name() === name) {
node.destroy();
callback && callback({ name });
resolve(true);
}
reject(false);
});
});
};
/**
*
* @returns 删除多边形
*/
const deletePolygon = (name: string) => {
const stage = stageRef.current;
const layer = stage?.getStage().findOne('.polygon-layer');
// 打印layer下的所有节点
console.log(layer?.getChildren());
const polygon = layer?.findOne(`.${name}`);
polygon?.destroy();
stage?.getStage().draw();
};
// 获取矩形数据
const getRectData = () => {
const stage = stageRef.current;
const layer = stage?.getStage().find('.rect-layer')[0];
const rects = layer?.find('Rect');
const data = rects?.filter((rect) => {
const { x, y, width, height, name } = rect.attrs;
return (
name && {
x,
y,
width,
height,
}
);
});
return data;
};
// 清空.layer-rect下的所有矩形
const clearAll = (shapes: string[]) => {
const stage = stageRef.current;
stage
?.getStage()
.find('Rect, Line')
.filter((node) => {
const shape = node.name();
if (shape.includes('rect') || shape.includes('polygon')) {
node.destroy();
}
});
stage?.getStage().draw();
};
useImperativeHandle(ref, () => ({
draw: drawFn,
edit: editFn,
getRectData,
clearAll,
deleteRect,
drawPolygonFn,
editPolygonFn,
deletePolygon,
deleteOneShape,
}));
// 监听imageSrc变化
useEffect(() => {
if (imgSrc) {
const image = new window.Image();
image.src = imgSrc;
image.onload = function () {
const { width, height } = this;
setImgeSize({
width,
height,
});
setloadImage(image);
};
}
}, [imgSrc]);
return (
<React.Fragment>
<Stage width={width} height={height} ref={stageRef}>
<Layer>
{loadImage && (
<Image image={loadImage} width={ImgeSize.width} height={ImgeSize.height} x={0} y={0} />
)}
</Layer>
<Layer name="rect-layer">
<KonvaRect
drawable={drawRact}
callback={(data) => {
setSinglePoint({ ...data });
}}
initialRectData={initialRectData}
drawEndFn={drawEndFn}
editable={editable}
/>
</Layer>
<Layer name="polygon-layer">
<KonvaPolygon
drawable={drawPolygon}
callback={(data) => {
// setSinglePoint({ ...data });
}}
initialRectData={initialRectData}
drawEndFn={drawEndFn}
editable={polygonEditable}
/>
</Layer>
</Stage>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<p>x: {SinglePoint?.x}</p>
<p>y: {SinglePoint?.y}</p>
<p>width: {SinglePoint?.width}</p>
<p>height: : {SinglePoint?.height}</p>
</div>
</React.Fragment>
);
};
export default KonvaStage;
参数说明
width: number;
height: number;
children?: React.ReactNode;
imgSrc?: string;
// Promise函数 drawEndFn
drawEndFn?: (data: Record<string, any>) => Promise<boolean>;
// 初始矩形数据
initialRectData?: Record<string, any>[];
场景的大小必须是数字,这是由konva的参数决定。我们需要将视频截取一帧,作为区域编辑的地图,所以,我们需要将图片的参数 imgSrc
传入。
deawEndFn
的主要作用,是为了在绘制完图形之后,我们需要在调用组件的地方,做自定义扩展,比如,我们在绘制结束之后,需要弹窗提示是否需要保存当前绘制图形。
initialRectData
的作用是为了编辑使用带入初始化图形数据。
stage初始化
<Stage width={width} height={height} ref={stageRef}>
<Layer>
{loadImage && (
<Image image={loadImage} width={ImgeSize.width} height={ImgeSize.height} x={0} y={0} />
)}
</Layer>
<Layer name="rect-layer">
<KonvaRect
drawable={drawRact}
callback={(data) => {
setSinglePoint({ ...data });
}}
initialRectData={initialRectData}
drawEndFn={drawEndFn}
editable={editable}
/>
</Layer>
<Layer name="polygon-layer">
<KonvaPolygon
drawable={drawPolygon}
callback={(data) => {
// setSinglePoint({ ...data });
}}
initialRectData={initialRectData}
drawEndFn={drawEndFn}
editable={polygonEditable}
/>
</Layer>
</Stage>
重点: 所有的操作不与Stage层绑定,如移动、缩放、事件等,确保整个场景纯真,这里分了三个Layer
图层,分别实现:图片绘制、矩形绘制与多边形绘制。
Rect多边形组件开发
import { animated, useSpring } from '@react-spring/konva';
import React, { useEffect, useRef, useState } from 'react';
import { Rect, Transformer } from 'react-konva';
import { generateUUID } from 'utils';
const KonvaRect = (props: KonvaRectProps) => {
const { drawable = false, editable = false, callback, drawEndFn, initialRectData = [] } = props;
const [rectangles, setRectangles] = useState<Record<string, any>[]>(initialRectData);
const [drawing, setDrawing] = useState(false);
const [startPos, setStartPos] = useState({ x: 0, y: 0 });
const [endPos, setEndPos] = useState({ x: 0, y: 0 });
const [isHovered, setIsHovered] = useState<boolean>(false);
const [isSelected, setIsSelected] = useState(false);
const trRef = useRef(null);
const [RectNode, setRectNode] = useState();
const circleAnimation = useSpring({
scale: isHovered ? 1.5 : 1,
opacity: isHovered ? 0.8 : 0.5,
config: { tension: 20, friction: 10 }, // 可根据需求调整动画效果
});
// 设置animated动画自动执行
useEffect(() => {
const animate = setInterval(() => {
setIsHovered((flat) => !flat);
}, 800);
// 销毁requestAnimationFrame
return () => clearInterval(animate);
}, []);
const handleMouseDown = (event) => {
setDrawing(true);
setStartPos({ x: event.evt.layerX, y: event.evt.layerY });
setEndPos({ x: event.evt.layerX, y: event.evt.layerY });
};
const handleMouseMove = (event) => {
if (drawing) {
setEndPos({ x: event.evt.layerX, y: event.evt.layerY });
}
};
//处理正在画的rect x、y、width、height 执行callback
useEffect(() => {
if (drawing) {
callback &&
callback({
x: Math.min(startPos.x, endPos.x),
y: Math.min(startPos.y, endPos.y),
width: Math.abs(endPos.x - startPos.x),
height: Math.abs(endPos.y - startPos.y),
});
}
}, [endPos.x, endPos.y]);
const handleMouseUp = async () => {
const rectData = {
x: Math.min(startPos.x, endPos.x),
y: Math.min(startPos.y, endPos.y),
width: Math.abs(endPos.x - startPos.x),
height: Math.abs(endPos.y - startPos.y),
name: `rect-${generateUUID()}`,
};
// 如果没有运笔,直接返回
if (rectData.width < 10 || rectData.height < 10) {
setDrawing(false);
return;
}
const flat = await drawEndFn(rectData);
if (flat) {
setRectangles([...rectangles, { ...rectData }]);
}
setDrawing(false);
};
const handleOnClick = (e) => {
setRectNode(e.target);
setIsSelected(true);
};
const [MenuVisible, setMenuVisible] = useState(false);
const [MenuPos, setMemuPos] = useState({ x: 0, y: 0 });
const handleContextMenu = (e) => {
e.evt.preventDefault();
handleOnClick(e);
setMenuVisible(true);
setMemuPos({ x: e.evt.layerX, y: e.evt.layerY });
};
const handleDeleteRect = () => {
const newRectangles = rectangles.filter((rect) => rect.name !== 'rect');
setRectangles(newRectangles);
setMenuVisible(false);
};
return (
<React.Fragment>
{MenuVisible && (
<div
style={{
position: 'fixed',
left: MenuPos.x,
top: MenuPos.y,
backgroundColor: 'white',
boxShadow: '0 0 5px gray',
padding: '5px',
}}
>
<div onClick={handleDeleteRect}>Delete</div>
</div>
)}
{endPos.x !== 0 && drawable && (
<animated.Circle
x={endPos.x}
y={endPos.y}
radius={5}
fill="yellow"
opacity={circleAnimation.opacity}
scaleX={circleAnimation.scale}
scaleY={circleAnimation.scale}
/>
)}
{rectangles.map((rect, i) => (
<Rect
key={i}
x={rect.x}
y={rect.y}
name={rect.name}
width={rect.width}
height={rect.height}
stroke="red"
strokeWidth={2}
fill="rgba(255, 255, 255, 0.2)"
shadowColor="black"
shadowBlur={10}
shadowOpacity={0.5}
shadowOffsetX={2}
shadowOffsetY={2}
draggable={editable}
onClick={handleOnClick}
onDragMove={(e) => {
callback && callback(e.target.attrs);
}}
onContextMenu={handleContextMenu}
onMouseOver={(e) => {
// 设置背景颜色加深,阴影加深
e.target.shadowBlur(20);
// 修改背景颜色
e.target.fill('rgba(255, 194, 16, 0.2)');
}}
onMouseLeave={(e) => {
e.target.shadowBlur(10);
e.target.fill('rgba(255, 255, 255, 0.2)');
}}
onTransformEnd={(e) => {
const node = e.target;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
node.width(node.width() * scaleX);
node.height(node.height() * scaleY);
node.scaleX(1);
node.scaleY(1);
const transformer = trRef.current;
transformer.forceUpdate();
callback && callback(node.attrs);
}}
/>
))}
{drawing && (
<Rect
x={Math.min(startPos.x, endPos.x)}
y={Math.min(startPos.y, endPos.y)}
width={Math.abs(endPos.x - startPos.x)}
height={Math.abs(endPos.y - startPos.y)}
stroke="yellow"
strokeWidth={2}
fill="rgba(255, 255, 205, 0.5)"
shadowColor="black"
shadowBlur={10}
shadowOpacity={0.3}
shadowOffsetX={5}
shadowOffsetY={5}
/>
)}
{drawable && (
<Rect
x={0}
y={0}
width={window.innerWidth}
height={window.innerHeight}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
/>
)}
{isSelected && editable && (
<Transformer
ref={(node) => {
if (node !== null) node?.getLayer().batchDraw();
}}
node={RectNode}
onTransformEnd={(e) => {
const node = e.target.node();
const scaleX = node.scaleX();
const scaleY = node.scaleY();
const x = node.x();
const y = node.y();
// set line position
node.setPosition({
x: x * scaleX,
y: y * scaleY,
});
}}
/>
)}
</React.Fragment>
);
};
export default KonvaRect;
同样,我们介绍下入参的定义。
interface KonvaRectProps {
drawable?: boolean; // 是否可绘制
editable?: boolean; // 是否可编辑
callback?: (rectangles: Record<string, any>[]) => void; // 回调函数
drawEndFn: (data: Record<string, any>) => Promise<any>; // Promise函数 drawEndFn
initialRectData?: Record<string, any>[]; // 初始矩形数据
}
这里有个小的操作细节,我们不将事件绑定在Stage
中,但我们在Rect组件中,申明了一个宽高与外层容易一样的Rect,将事件绑定在Rect图形上,这样,就相当于给整个场景,添加了一层事件蒙版,所有的操作都在蒙版上面进行。确保与其他操作分离。
polygon
的绘制思路与Rect基本一致,可参考绘制Rect矩形的代码参考,效果如下:
这里使用到 react-konva-utils
的Html
组件,它可以允许我们讲html代码片段植入到场景中,使我们在编写业务的时候,多了一种可玩性。
<Stage width={width} height={height} ref={stageRef} draggable onWheel={handleWheel}>
<Layer>
{loadImage && (
<Image
image={loadImage}
width={ImgeSize.width}
height={ImgeSize.height}
onContextMenu={handleDblClick}
/>
)}
{cameraList.map((item) => {
return (
<Html
divProps={{
style: {
top: `${(item.y - 20) * scale}px`,
left: `${(item.x - 20) * scale}px`,
},
}}
>
{props.children}
</Html>
);
})}
</Layer>
</Stage>
PS
目前阶段功能还没开发完,很多代码还没有与实际业务场景融合,分享给大家作为一个参考,欢迎来扰👻