最近帮朋友做了一个这样的需求:给定一张户型图以及一个设备列表,用户点击户型图的某个位置可以选择设备进行安放,有点类似地图打点功能。
思路一
看到需求我的第一想法就是采用 canvas
实现,于是便有了以下对话:
我:可以使用canvas实现,户型图作为背景图,用户在某个位置点击选择好设备之后,将设备的缩略图绘制到该位置。
朋友:用canvas的话,打好点之后就是一整张canvas图了吧,那后面点击该设备点位查看详情的时候我咋知道我点的是哪个设备啊。
我:让我想想...
我:设备点位的位置信息我们是有传给后台是吧,那我们就循环设备点位的列表,一个个去比对。
朋友:emmm.....可是我还想做报警功能,比如说某个设备点位触发了报警,该点位可能就需要有闪动效果。
我:哈哈哈,那貌似有点为难,那我再想想。
一次次battle之后否决了这个思路。略加思考之后进行了第二次讨论。
思路二
我:那咱们改成用div实现吧。
朋友:怎么说?
我:定义一个div,设置相对定位,将户型图作为它的背景图,用户在某个位置点击选择好设备之后,添加一个img标签引用该设备缩略图然后将其定位在该位置。
朋友:貌似可行,但是要用一个div将img包裹起来,因为后续做报警功能时需要添加高亮类的样式。
我:可以的,你看看还需要做什么功能?
朋友:打好设备点位的户型图还需要导出来。
我:那可以采用第三方库 html2canvas 将其转为图片,或者说采用第三方库 html2pdf.js 将其转为pdf导出也是可以的,看你想要什么样的效果。
朋友:转为pdf导出吧。
我:好,那就开干!
思路一实现
行动派的我,讨论的同时就已经开干了,所以也实现了思路一的想法。
新建一个 react
项目:
lua
npx create-react-app my-app
先说一下项目的具体实现,用户右键点击户型图的时候记录下当前鼠标位置,然后弹出一个设备选择弹窗,选择好设备点击确认会再次弹出一个弹窗,填写该点位的地址编号等信息,填写完成点击确认,将设备相关信息及位置信息传递给后台之后关闭弹窗,在该点绘制一个设备点位,即将该设备的缩略图绘制上去。左键点击该设备点位,会展开一个抽屉展示该设备点位的详细信息,以及删除、编辑等按钮操作。
先来看下最终的实现效果:
然后来看下 canvas
版代码实现,这里仅实现关键部分:
先来实现整体架构,修改 App.js
:
ini
import React, { useEffect, useRef, useState } from "react";
import DeviceModal from "./DeviceModal
import AddModal from "./AddModal";
import ViewModal from "./ViewModal";
import floorPlanImg from "./assets/1.jpg";
import thumbnail1 from "./assets/2.jpg";
import thumbnail2 from "./assets/3.jpg";
import "./App.css";
const App = () => {
const [isDeviceModalOpen, setIsDeviceModalOpen] = useState(false);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
const [currentDevice, setCurrentDevice] = useState({}); // 当前设备点位信息
const [dotList, setDotList] = useState([
// 打好的点的数据列表,本文没有模拟接口请求,所以写了几条假数据
{
id: "1",
deviceId: "1",
name: "设备一",
host: "设备一主机",
addressNo: "设备一地址编号",
thumbnail: thumbnail1,
x: 94,
y: 129,
},
{
id: "2",
deviceId: "2",
name: "设备二",
host: "设备二主机",
addressNo: "设备二地址编号",
thumbnail: thumbnail2,
x: 532,
y: 134,
},
]);
const floorPlanRef = useRef(null);
useEffect(() => {
// 初始化,绘制户型图,以及从后台接口获取已经打好点的设备点位列表
const handleInit = async () => {
};
handleInit();
}, []);
// 左键点击
const handleClick = (event) => {
};
// 右键点击
const handleContextMenu = (event) => {
};
// 选择设备弹窗点击确认按钮后调用
const handleDeviceModalSubmit = (device) => {
};
// 设备信息填写完成且接口请求成功后调用
const handleAddModalSubmitSuccess = () => {
};
return (
<div className="App">
<div className="floorPlanWrap">
<canvas
width={800}
height={558}
ref={floorPlanRef}
onContextMenu={handleContextMenu}
onClick={handleClick}
></canvas>
</div>
<DeviceModal
isModalOpen={isDeviceModalOpen}
onSubmit={handleDeviceModalSubmit}
/>
<AddModal
isModalOpen={isAddModalOpen}
currentDevice={currentDevice}
onSubmitSuccess={handleAddModalSubmitSuccess}
/>
<ViewModal
isModalOpen={isViewModalOpen}
currentDevice={currentDevice}
/>
</div>
);
};
export default App;
先来实现绘图方法,采用 Canvas drawImage() 方法,该方法接受五个参数:要使用的图片、在画布上放置图像的 x 坐标位置、在画布上放置图像的 y 坐标位置、要使用的图像的宽度、要使用的图像的高度:
ini
const drawImage = (imgSrc, x, y, width, height) =>
new Promise((resolve, reject) => {
const canvas = floorPlanRef.current;
const context = canvas.getContext("2d");
let img = new Image();
img.src = imgSrc;
img.onload = () => {
context.drawImage(img, x, y, width, height);
resolve();
};
});
初始化方法 handleInit
调用绘图方法进行绘制:
javascript
const handleInit = async () => {
// 先绘制背景图
await drawImage(
floorPlanImg,
0,
0,
floorPlanRef.current.width,
floorPlanRef.current.height
);
// 再绘制已经打好的设备点位
dotList.forEach((item) => {
drawImage(
item.thumbnail,
item.x,
item.y,
50,
50
);
});
};
右键点击的时候我们首先需要先禁用掉默认行为,计算出鼠标的位置,然后打开设备选择弹窗:
ini
const handleContextMenu = (event) => {
event.preventDefault();
// event.pageX,event.pageY坐标相对于整个渲染页面的左上角(包括滚动隐藏距离)
// offsetLeft:距离上一级定位元素左侧的距离 offsetTop:距离上一级定位元素上侧的距离
const x = event.pageX - floorPlanRef.current.offsetLeft;
const y = event.pageY - floorPlanRef.current.offsetTop;
setIsDeviceModalOpen(true);
setCurrentDevice({ x, y });
}
选择好设备点击确认后,我们需要先保存选择的设备信息,然后再关闭设备选择弹窗,打开信息编辑弹窗:
scss
const handleDeviceModalSubmit = (device) => {
setCurrentDevice({
...currentDevice,
...device
});
setIsDeviceModalOpen(false);
setIsAddModalOpen(true);
};
信息编辑好提交成功之后,关闭弹窗,将该设备点位绘制到户型图上:
ini
const handleAddModalSubmitSuccess = (device) => {
setIsAddModalOpen(false);
drawImage(
device.thumbnail,
device.x,
device.y,
50,
50
);
};
最后来实现下点击的时候获取设备点位详情,我们需要将当前鼠标位置信息与已经打好的设备点位列表的位置一一比对,同时符合以下情况则比对成功:
- 当前鼠标位置的x坐标要大于等于该设备点位的x坐标。
- 当前鼠标位置的x坐标要小于等于该设备点位的x坐标加上缩略图的宽。
- 当前鼠标位置的y坐标要大于等于该设备点位的y坐标。
- 当前鼠标位置的y坐标要小于等于该设备点位的y坐标加上缩略图的高。
实现如下:
ini
const handleClick = (event) => {
const x = event.pageX - floorPlanRef.current.offsetLeft;
const y = event.pageY - floorPlanRef.current.offsetTop;
for (let i = 0; i < dotList.length; i++) {
let el = dotList[i];
if (x >= el.x && x <= el.x + 50 && y >= el.y && y <= el.y + 50) {
setCurrentDevice(el);
setIsViewModalOpen(true);
break;
}
}
};
实现下来发现这个思路的确不太行。。。让我们来看下第二种思路的实现。
思路二实现
整体架构跟第一种思路是一样的。需要对 HTML
部分做些许改动:
ini
<div className="App">
<div className="others">其它元素</div>
<Button type="primary" onClick={handlePrint}>
打印户型图
</Button>
<div
ref={floorPlanRef}
className="floorPlan"
style={{ backgroundImage: floorPlanImg }}
onContextMenu={handleContextMenu}
>
{dotList.map((item) => {
return (
<img
alt="设备点位"
src={item.thumbnail}
style={{ left: item.x, top: item.y }}
onClick={() => handleView(item)}
onContextMenu={(event) => event.stopPropagation()}
/>
);
})}
</div>
// 下面是几个弹窗,跟思路一实现一样
</div>
这部分的css实现如下:
css
.floorPlan{
position: relative;
width: 800px;
height: 558px;
background-size: cover;
img{
position: absolute;
width: 50px;
height: 50px;
}
}
这种思路就方便多了,初始化方法中就只要从后台接口获取设备点位列表, 点击设备点位的时候可以直接获取该设备点位的信息,信息编辑完成之后需要重新调用获取设备点位列表接口获取最新的设备点位信息。所以需要对以下几个方法做些调整:
javascript
const handleInit = async () => {
// 该方法中需要获取设备点位列表,然后赋值给dotList
}
const handleAddModalSubmitSuccess = () => {
setIsAddModalOpen(false);
// 重新获取设备点位列表,然后赋值给dotList
}
最后来实现以下打印功能,打印功能使用的第三方库为 html2pdf.js:
先来进行安装:
css
npm install --save html2pdf.js
handlePrint
方法实现:
yaml
const handlePrint = () => {
const opt = {
margin: 0,
filename: "户型图.pdf",
pagebreak: { mode: ["avoid-all", "css", "legacy"] },
image: { type: "jpeg", quality: 1 },
enableLinks: true,
html2canvas: { scale: 2, useCORS: true, allowTaint: false },
jsPDF: {},
};
html2pdf(floorPlanRef.current, opt);
};
完结,撒花~