wuhan-gl/src/views/plan/shiyan.vue

2503 lines
63 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div id="app">
<div
class="container"
v-loading="loading"
element-loading-background="rgba(0, 0, 0, 0.8)"
>
<div class="app-container">
<div class="action-bar">
<div
class="action-bar-item spicon"
@click="editFun"
data-driver-step="edit-button"
>
<el-tooltip
class="item"
effect="dark"
:content="isEdit ? '取消编辑' : '编辑'"
placement="top"
>
<img v-if="isEdit" src="@/assets/edit-yes.png" alt="" />
<img v-else src="@/assets/edit-no.png" alt="" />
</el-tooltip>
</div>
<div class="action-bar-item spicon" @click="resetView">
<el-tooltip
class="item"
effect="dark"
content="重置视图"
placement="top"
>
<img src="@/assets/bg-reset.png" alt="" />
</el-tooltip>
</div>
</div>
<!-- <div class="current-icon-info" v-if="currentPointX && currentPointY">
<span>X: {{ currentPointX.toFixed(0) }}</span>
<span>Y: {{ currentPointY.toFixed(0) }}</span>
</div> -->
<div class="canvas-container">
<div
class="tabbar-content"
v-show="showFlag"
data-driver-step="device-type-list"
>
<div
class="tabbar-item"
v-for="(item, index) in typesDictionary"
:class="item.isSelected ? 'show' : 'hide'"
@click="handleTabbar(item)"
:data-driver-step="index === 0 ? 'device-type-item' : undefined"
>
<div class="tabbar-item-img">
<img :src="icons[getIcon(item.model_id)]?.src" alt="" />
</div>
<div>{{ item.model_name }}</div>
</div>
</div>
<div class="canvas-wrapper">
<!-- 底层Canvas仅绘制背景图静态内容 -->
<canvas ref="bgCanvasRef" class="bg-canvas"></canvas>
<!-- 上层Canvas绘制图表、标记点等动态内容 -->
<canvas
ref="canvasRef"
@mousedown="startDrag"
@mousemove="onMouseMove"
@mouseup="mouseUp"
@mouseleave="mouseLeave"
@wheel.prevent="handleWheel"
@dblclick="handleCanvasClick"
:class="{ 'smooth-transition': isAnimating }"
:style="`cursor: ${cursorStyle}`"
class="main-canvas"
></canvas>
</div>
</div>
</div>
<!-- <div class="charts-content">
<div ref="chartRef" class="line-chart"></div>
</div> -->
</div>
<div class="progress-container spslider">
<div class="bar-content bar-contentsp slider-content">
<div
class="slider-item"
:class="item.area === selectAreaId ? 'is-select-area' : ''"
v-for="(item, index) in region"
@click="handleProgressChange2(item)"
>
{{ item.name }}
</div>
</div>
</div>
<template v-for="(item, index) in dialogList">
<template v-if="item.target == 'device'">
<dialogEle
:modelValue="item.show"
:id="item.id"
:AreaId="item.AreaId"
:TypeId="item.TypeId"
:TypeName="item.TypeName"
:dialogIndex="index"
:zIndex="item.index"
:extra="item.extra"
@up:zindex="upDialogZindex"
@close:dialog="closeDialog"
@link:openCameraDialog="openCameraDialog"
></dialogEle>
</template>
<template v-if="item.target == 'camera'">
<videoEle
:info="item.info"
:dialogIndex="index"
:zIndex="item.index"
:modelValue="item.show"
@up:zindex="upDialogZindex"
@close:dialog="closeDialog"
></videoEle>
</template>
</template>
<tableEle
:modelValue="tableModelValue"
@update:modelValue="updateTableModelValue"
></tableEle>
</div>
</template>
<script setup>
import { throttle } from "lodash";
import { getArea } from "@/api/area.js";
import { getDeviceType, getDevices, updateDevice } from "@/api/device.js";
import { ElMessage, ElNotification } from "element-plus";
import dialogEle from "./../components/dialogEle.vue";
import { ref, onMounted, onUnmounted, reactive, nextTick, provide, watch } from "vue";
import { useRoute } from "vue-router";
import { shiyan_typesDictionary } from "../../utils/equipmentType"; //设备类型字典
import videoEle from "./../components/videoEle.vue";
import tableEle from "./../components/tableEle.vue";
import * as echarts from "echarts";
import { registerPagePrepare, unregisterPagePrepare } from "@/utils/userGuide";
// 通过环境变量动态加载资源
const assetsVersion = import.meta.env.VITE_ASSETS_VERSION || "shiyan";
const bg0101 = new URL(
`../../assets/${assetsVersion}/0101.png`,
import.meta.url
).href;
const bg0102 = new URL(
`../../assets/${assetsVersion}/0102.png`,
import.meta.url
).href;
const bg0201 = new URL(
`../../assets/${assetsVersion}/0201.png`,
import.meta.url
).href;
const bg0202 = new URL(
`../../assets/${assetsVersion}/0202.png`,
import.meta.url
).href;
const bg0301 = new URL(
`../../assets/${assetsVersion}/0301.png`,
import.meta.url
).href;
const bg0302 = new URL(
`../../assets/${assetsVersion}/0302.png`,
import.meta.url
).href;
const bg0401 = new URL(
`../../assets/${assetsVersion}/0401.png`,
import.meta.url
).href;
const bg0402 = new URL(
`../../assets/${assetsVersion}/0402.png`,
import.meta.url
).href;
const ground = new URL(
`../../assets/${assetsVersion}/ground.png`,
import.meta.url
).href;
// 2. 存储图片路径列表和转换后的ImageBitmap
const imagePaths = [
bg0101,
bg0102,
bg0201,
bg0202,
bg0301,
bg0302,
bg0401,
bg0402,
ground,
]; // 9张图的路径数组
const imageBitmaps = ref([]); // 存储最终的ImageBitmap对象
const error = ref(""); // 错误信息
let fixArea = [
"0101",
"0102",
"0201",
"0202",
"0301",
"0302",
"0401",
"0402",
"ground",
];
// 优化:添加缩放状态管理
const isZooming = ref(false);
let zoomEndTimer = null;
// 节流处理每16ms最多重绘一次上层动态内容
const throttledRedraw = throttle((item) => {
draw();
}, 16); // 16ms ~= 60fps
// 优化:背景重绘节流,缩放时使用更低频率
const throttledReBGdraw = throttle((item) => {
if (!isZooming.value) {
drawBackground();
}
}, 16); // 16ms ~= 60fps
// 优化:缩放时的背景重绘(更低频率,降低质量)
const throttledReBGdrawDuringZoom = throttle((item) => {
drawBackground();
}, 50); // 50ms缩放时降低频率
const tableModelValue = ref(false);
// 新增底层背景Canvas引用
const bgCanvasRef = ref(null);
// 上层主Canvas引用原canvasRef
const canvasRef = ref(null);
// 新增底层背景Canvas上下文
const bgCtx = ref(null);
// 上层主Canvas上下文原ctx
const ctx = ref(null);
const img = new Image();
// 优化离屏Canvas用于缓存背景避免重复绘制
let offscreenCanvas = null;
let offscreenCtx = null;
let cachedScale = null;
let cachedOffsetX = null;
let cachedOffsetY = null;
let _typesDictionary = reactive(shiyan_typesDictionary);
const typesDictionary = ref([]);
// 恢复points定义 - 点标记数据 设备数据
let points = reactive([]);
// 状态管理(保持不变)
const scale = ref(1);
const offsetX = ref(0);
const offsetY = ref(0);
const startX = ref(0);
const startY = ref(0);
const dragging = ref(null);
const isDragging = ref(false);
const mouseX = ref(0);
const mouseY = ref(0);
const pointSize = ref(3);
const iconSize = ref(5);
const selectedPointId = ref(null);
const isAnimating = ref(false);
const loading = ref(false);
const startPointX = ref(0);
const startPointY = ref(0);
const startDragX = ref(0);
const startDragY = ref(0);
const progress = ref(0);
const isEdit = ref(false);
const showFlag = ref(true);
const selectAreaId = ref(1);
const cursorStyle = ref("");
const activeReferenceLine = ref(null);
let currentPoint = reactive({});
// let currentPointX = ref(0);
// let currentPointY = ref(0);
let dialogList = reactive([]);
const deviceList = ref([]);
let StandardC = 948; //设置标准系数 初始化数据电脑屏幕的高度
let BL = ref(0); //标准系数 / 当前屏幕高度 计算出的系数比例 保证在各高度设备上展示正常
// 从配置文件中读取区域配置,如果没有则使用默认值
const region = ref(
window.ShiYanRegionConfig || [
{ area: 1, x: 0, y: 450, name: "1栋1单元", code: '0101' },
{ area: 2, x: 0, y: 240, name: "1栋2单元", code: '0102' },
{ area: 3, x: 450, y: 410, name: "2栋1单元", code: '0201' },
{ area: 4, x: 450, y: 200, name: "2栋2单元", code: '0202' },
{ area: 5, x: 1100, y: 430, name: "3栋1单元", code: '0301' },
{ area: 6, x: 1100, y: 220, name: "3栋2单元", code: '0302' },
{ area: 7, x: 100, y: 0, name: "4栋1单元", code: '0401' },
{ area: 8, x: 800, y: 0, name: "4栋2单元", code: '0402' },
{ area: 9, x: 200, y: 600, name: "广场", code: 'ground' },
]
);
// 背景图信息(保持不变)
const imgAspectRatio = ref(1);
const imgWidth = ref(0);
const imgHeight = ref(0);
// 鼠标移动检测相关变量(保持不变)
const lastMousePosition = ref({ x: 0, y: 0 });
//悬浮展示设备 摄像头基本信息相关(保持不变)
const activeMarker = ref({ target: "" });
const SELECT_ACTION_TYPE = ref("");
const icons = {
yyht: new Image(),
eyht: new Image(),
wenshidu: new Image(),
yangqi: new Image(),
yewei: new Image(),
sll: new Image(),
shuiwen: new Image(),
sgxwd: new Image(),
youwei: new Image(),
youqi: new Image(),
fengliang: new Image(),
fengji: new Image(),
ddff: new Image(),
sb: new Image(),
yb: new Image(),
znzm: new Image(),
xfzj: new Image(),
pingjun: new Image(),
ktjz: new Image(),
xfjwjz: new Image(),
cyfdjz: new Image(),
xfbjzj: new Image(),
mj: new Image(),
spjk: new Image(),
znyb: new Image(),
weiji: new Image(),
zhiliuping: new Image(),
byqwky: new Image(),
wyc: new Image(),
};
icons.yyht.src = new URL(
"../../assets/planIcon/yyht.svg",
import.meta.url
).href;
icons.eyht.src = new URL(
"../../assets/planIcon/eyht.svg",
import.meta.url
).href;
icons.wenshidu.src = new URL(
"../../assets/planIcon/wenshidu.svg",
import.meta.url
).href;
icons.yangqi.src = new URL(
"../../assets/planIcon/yangqi.svg",
import.meta.url
).href;
icons.yewei.src = new URL(
"../../assets/planIcon/yewei.svg",
import.meta.url
).href;
icons.wyc.src = new URL("../../assets/planIcon/wyc.svg", import.meta.url).href;
icons.sll.src = new URL("../../assets/planIcon/sll.svg", import.meta.url).href;
icons.shuiwen.src = new URL(
"../../assets/planIcon/shuiwen.svg",
import.meta.url
).href;
icons.sgxwd.src = new URL(
"../../assets/planIcon/sgxwd.svg",
import.meta.url
).href;
icons.youwei.src = new URL(
"../../assets/planIcon/youwei.svg",
import.meta.url
).href;
icons.youqi.src = new URL(
"../../assets/planIcon/youqi.svg",
import.meta.url
).href;
icons.fengliang.src = new URL(
"../../assets/planIcon/fengliang.svg",
import.meta.url
).href;
icons.fengji.src = new URL(
"../../assets/planIcon/fengji.svg",
import.meta.url
).href;
icons.ddff.src = new URL(
"../../assets/planIcon/ddff.svg",
import.meta.url
).href;
icons.sb.src = new URL("../../assets/planIcon/sb.svg", import.meta.url).href;
icons.yb.src = new URL("../../assets/planIcon/yb.svg", import.meta.url).href;
icons.znzm.src = new URL(
"../../assets/planIcon/znzm.svg",
import.meta.url
).href;
icons.xfzj.src = new URL(
"../../assets/planIcon/xfzj.svg",
import.meta.url
).href;
icons.pingjun.src = new URL(
"../../assets/planIcon/pingjun.svg",
import.meta.url
).href;
icons.ktjz.src = new URL(
"../../assets/planIcon/ktjz.svg",
import.meta.url
).href;
icons.xfjwjz.src = new URL(
"../../assets/planIcon/xfjwjz.svg",
import.meta.url
).href;
icons.cyfdjz.src = new URL(
"../../assets/planIcon/cyfdjz.svg",
import.meta.url
).href;
icons.xfbjzj.src = new URL(
"../../assets/planIcon/xfbjzj.svg",
import.meta.url
).href;
icons.mj.src = new URL("../../assets/planIcon/mj.svg", import.meta.url).href;
icons.spjk.src = new URL(
"../../assets/planIcon/spjk.svg",
import.meta.url
).href;
icons.znyb.src = new URL(
"../../assets/planIcon/znyb.svg",
import.meta.url
).href;
icons.weiji.src = new URL(
"../../assets/planIcon/weiji.svg",
import.meta.url
).href;
icons.zhiliuping.src = new URL(
"../../assets/planIcon/zhiliuping.svg",
import.meta.url
).href;
icons.byqwky.src = new URL(
"../../assets/planIcon/byqwky.svg",
import.meta.url
).href;
let referenceLines = []; //吸附区域y坐标
// let referenceLines = [355, 400, 460, 505]; //吸附区域y坐标
let snapThreshold = 15; //出现吸附区域的阈值
const updateTableModelValue = (value) => {
tableModelValue.value = value;
};
// 图表引用和实例
const chartRef = ref(null);
let chartInstance = null;
// 状态变量
const darkMode = ref(true);
const chartData = ref({
xAxis: [
"1月",
"2月",
"3月",
"4月",
"5月",
"6月",
"7月",
"8月",
"9月",
"10月",
"11月",
"12月",
],
series: [
{
name: "产品A",
data: [120, 132, 101, 134, 90, 230, 210, 230, 180, 230, 210, 250],
color: "#42b983", // 柔和的绿色
},
{
name: "产品B",
data: [220, 182, 191, 234, 290, 330, 310, 330, 380, 330, 310, 350],
color: "#3498db", // 柔和的蓝色
},
{
name: "产品C",
data: [150, 232, 201, 154, 190, 330, 410, 330, 380, 430, 410, 450],
color: "#f39c12", // 柔和的橙色
},
],
});
// 初始化图表
const initChart = () => {
// 销毁已有实例
if (chartInstance) {
chartInstance.dispose();
}
// 创建新实例
chartInstance = echarts.init(chartRef.value);
// 设置图表选项
const option = {
backgroundColor: darkMode.value
? "rgba(30, 30, 30, 0.7)"
: "rgba(255, 255, 255, 0.7)",
tooltip: {
trigger: "axis",
backgroundColor: darkMode.value
? "rgba(40, 40, 40, 0.9)"
: "rgba(255, 255, 255, 0.9)",
borderColor: darkMode.value ? "#555" : "#ddd",
textStyle: {
color: darkMode.value ? "#fff" : "#333",
},
padding: 10,
borderRadius: 6,
},
legend: {
data: chartData.value.series.map((item) => item.name),
top: 10,
textStyle: {
color: darkMode.value ? "#eee" : "#555",
},
},
grid: {
left: "3%",
right: "4%",
bottom: "3%",
containLabel: true,
},
xAxis: {
type: "category",
boundaryGap: false,
data: chartData.value.xAxis,
axisLine: {
lineStyle: {
color: darkMode.value ? "#555" : "#ddd",
},
},
axisLabel: {
color: darkMode.value ? "#bbb" : "#666",
},
splitLine: {
show: true,
lineStyle: {
color: darkMode.value
? "rgba(255, 255, 255, 0.05)"
: "rgba(0, 0, 0, 0.05)",
},
},
},
yAxis: {
type: "value",
axisLine: {
lineStyle: {
color: darkMode.value ? "#555" : "#ddd",
},
},
axisLabel: {
color: darkMode.value ? "#bbb" : "#666",
},
splitLine: {
show: true,
lineStyle: {
color: darkMode.value
? "rgba(255, 255, 255, 0.05)"
: "rgba(0, 0, 0, 0.05)",
},
},
},
series: chartData.value.series.map((item) => ({
name: item.name,
type: "line",
data: item.data,
symbol: "circle",
symbolSize: 6,
emphasis: {
symbolSize: 8,
},
lineStyle: {
width: 2,
color: item.color,
},
itemStyle: {
color: item.color,
borderWidth: 2,
borderColor: darkMode.value ? "#333" : "#fff",
},
areaStyle: {
color: {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: item.color + "80", // 透明度80%
},
{
offset: 1,
color: item.color + "00", // 透明度0%
},
],
},
},
})),
};
// 设置选项
chartInstance.setOption(option);
};
// 3. 加载单张图片并转换为ImageBitmap的工具函数
async function loadLocalImageBitmap(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`图片加载失败: ${url}(状态码:${response.status}`);
}
const blob = await response.blob();
return await createImageBitmap(blob); // 转换为高性能ImageBitmap
}
// 4. 批量加载所有图片,全部完成后触发操作
const loadAllImages = async (images) => {
loading.value = true;
// 使用Promise.all等待9张图片全部转换完成
const bitmaps = await Promise.all(
images.map((path) => loadLocalImageBitmap(path))
);
imageBitmaps.value = bitmaps;
error.value = "";
return bitmaps; // 返回加载完成的ImageBitmap数组
};
const handleTabbar = (e) => {
e.isSelected = !e.isSelected;
selectedPointId.value = null;
typesDictionary.value.forEach((d) => {
if (d.model_id === e.model_id && d.isSelected) {
d.isSelected = true;
} else {
d.isSelected = false;
}
});
if (e.isSelected) {
SELECT_ACTION_TYPE.value = e.model_id;
} else {
SELECT_ACTION_TYPE.value = "";
}
showFlag.value = false;
nextTick(() => {
showFlag.value = true;
});
throttledRedraw(); // 只重绘上层动态内容
};
const editFun = () => {
isEdit.value = !isEdit.value;
if (!isEdit.value) {
throttledRedraw(); // 只重绘上层动态内容
}
};
// 根据 code 从 region 配置中获取 label 的辅助函数
const getLabelByCode = (code) => {
const matchedRegion = region.value.find((r) => r.code === code);
return matchedRegion ? matchedRegion.name : '';
};
const rects = reactive([
{
code: "0401",
y: 10,
label: getLabelByCode("0401"),
height: 220,
},
{
code: "0402",
y: 10,
label: getLabelByCode("0402"),
height: 220,
},
]);
const rects1 = reactive([
{
code: "0102",
y: 240,
label: getLabelByCode("0102"),
},
{
code: "0202",
y: 240,
label: getLabelByCode("0202"),
},
{
code: "0302",
y: 240,
label: getLabelByCode("0302"),
},
]);
const rects2 = reactive([
{
code: "0101",
y: 430,
label: getLabelByCode("0101"),
},
{
code: "0201",
y: 430,
label: getLabelByCode("0201"),
},
{
code: "0301",
y: 430,
label: getLabelByCode("0301"),
},
]);
const rect_ground = reactive([
{
code: "ground",
y: 620,
label: getLabelByCode("ground"),
},
]);
// 监听 region 变化,更新所有 rects 的 label
const updateRectsLabels = () => {
rects.forEach((rect) => {
rect.label = getLabelByCode(rect.code);
});
rects1.forEach((rect) => {
rect.label = getLabelByCode(rect.code);
});
rects2.forEach((rect) => {
rect.label = getLabelByCode(rect.code);
});
rect_ground.forEach((rect) => {
rect.label = getLabelByCode(rect.code);
});
};
// 监听 region 变化
watch(
() => region.value,
() => {
updateRectsLabels();
},
{ deep: true, immediate: true }
);
let s_w = 0.8;
const getDeviceTypeFun = async () => {
let params = {
page: 1,
page_size: 100,
};
let res = await getDeviceType(params);
if (res && res.code == 200 && res.data) {
let list = res.data.device_types;
typesDictionary.value = list;
}
};
const getDevicesFun = async () => {
let params = {
page: 1,
page_size: 1000,
};
let res = await getDevices(params);
if (res && res.code == 200 && res.data) {
let list = res.data.devices || [];
// 1) 按 region_name 分组设备,用于计算同一区域内的索引
const devicesByRegion = {};
list.forEach((item) => {
const regionName = item.region_name || "";
if (!devicesByRegion[regionName]) {
devicesByRegion[regionName] = [];
}
devicesByRegion[regionName].push(item);
});
deviceList.value = list.map((item) => {
const regionName = item.region_name || "";
const matchedRegion = region.value.find((r) => r.name === regionName);
let finalX = item.x ?? 300;
let finalY = item.y ?? 300;
if (matchedRegion) {
finalX = matchedRegion.x;
finalY = matchedRegion.y + 50;
const regionDevices = devicesByRegion[regionName] || [];
const deviceIndex = regionDevices.findIndex((d) => {
// 使用唯一标识来匹配,优先使用 DeviceId如果没有则使用其他唯一字段
return d.IotDevice?.id === item.IotDevice?.id;
});
// 根据索引逐次增加 x 坐标(第一个设备 x + 0第二个 x + 50第三个 x + 100...
if (deviceIndex >= 0) {
finalX = matchedRegion.x + deviceIndex * 50;
}
}
return {
...item,
x:
item.IotDevice.coordinate_x && item.IotDevice.coordinate_x != 0
? item.IotDevice.coordinate_x
: finalX,
y:
item.IotDevice.coordinate_y && item.IotDevice.coordinate_y != 0
? item.IotDevice.coordinate_y
: finalY,
};
});
// 3) 将设备列表转成绘制需要的点数据结构(按区域分组)
// 提取 points 中每一项的 IotAgreementHostPoint.id
const extractPointIds = (points) => {
if (!points || !Array.isArray(points)) return [];
return points
.map((point) => point?.IotAgreementHostPoint?.id)
.filter((id) => id !== undefined && id !== null);
};
const mappedPoints = deviceList.value.map((item) => {
// parent_region_id 作为区域分组标识
const areaId = item.parent_region_id ?? 0;
return {
target: "device",
AreaId: areaId,
DeviceId: item.IotDevice.id,
TypeId: item.IotDevice.device_type_id,
TypeName: item.type_name,
DeviceName: item.IotDevice.device_name,
AreaName: item.region_name,
x: item.x,
y: item.y,
IsOpen: item.IsOpen || 0,
IsFault: item.IsFault || 0,
IsAlarm: item.IsAlarm || 0,
StateId: item.StateId || 0,
CabinId: item.CabinId || 0,
CabinName: item.CabinName || "",
Station: item.Station || 0,
Data: item.points || [],
Points: extractPointIds(item.points)
};
});
points = mappedPoints;
throttledReBGdraw();
throttledRedraw();
}
};
const getAreaFun = async () => {
let params = {
page: 1,
page_size: 10,
area_type: 1,
};
let res = await getArea(params);
if (res && res.code == 200) {
let areaList_arr = [];
let imagePaths = [];
res.data.areas.forEach((d) => {
if (fixArea.indexOf(d.area_code) != -1 && d.background) {
areaList_arr.push({
area_code: d.area_code,
background: window.$FileBaseUrl + d.background,
});
}
});
areaList_arr.forEach((d) => {
imagePaths.push(d.background);
});
const bitmaps = await loadAllImages(imagePaths);
areaList_arr.forEach((d, i) => {
rects.forEach((c) => {
if (c.code == d.area_code) {
c.colorBg = bitmaps[i];
}
});
rects1.forEach((c) => {
if (c.code == d.area_code) {
c.colorBg = bitmaps[i];
}
});
rects2.forEach((c) => {
if (c.code == d.area_code) {
c.colorBg = bitmaps[i];
}
});
rect_ground.forEach((c) => {
if (c.code == d.area_code) {
c.colorBg = bitmaps[i];
}
});
});
initCanvas();
} else {
initCanvas();
}
};
const drawColorBg = (ctx, img, x, y, w, h) => {
if (img) {
ctx.save();
ctx.globalAlpha = 0.5;
ctx.drawImage(img, x, y, w, h);
ctx.globalAlpha = 1;
ctx.restore();
}
};
const drawText = (ctx, text, x, y) => {
// 绘制文字(确保在最上层)
ctx.save();
ctx.font = "10px Arial";
ctx.fillStyle = "#fff";
ctx.textBaseline = "middle";
ctx.fillText(text, x, y);
ctx.restore();
};
// 优化初始化离屏Canvas
const initOffscreenCanvas = () => {
if (!bgCanvasRef.value) return;
const canvasWidth = bgCanvasRef.value.width;
const canvasHeight = bgCanvasRef.value.height;
// 创建离屏Canvas尺寸稍大以支持缓存
offscreenCanvas = document.createElement("canvas");
offscreenCanvas.width = canvasWidth;
offscreenCanvas.height = canvasHeight;
offscreenCtx = offscreenCanvas.getContext("2d");
// 设置离屏Canvas的渲染质量
offscreenCtx.imageSmoothingEnabled = true;
offscreenCtx.imageSmoothingQuality = "high";
};
// 优化:绘制底层背景图(使用缓存优化)
const drawBackground = () => {
if (!bgCtx.value || !img.complete) return;
const canvasWidth = bgCanvasRef.value.width;
const canvasHeight = bgCanvasRef.value.height;
// 初始化离屏Canvas
if (
!offscreenCanvas ||
offscreenCanvas.width !== canvasWidth ||
offscreenCanvas.height !== canvasHeight
) {
initOffscreenCanvas();
}
// 计算背景图尺寸(与原逻辑一致)
imgHeight.value = canvasHeight;
imgWidth.value = imgHeight.value * imgAspectRatio.value;
// 检查是否需要重新绘制(缓存失效)
const needRedraw =
cachedScale !== scale.value ||
cachedOffsetX !== offsetX.value ||
cachedOffsetY !== offsetY.value ||
!offscreenCanvas;
if (needRedraw) {
// 清除离屏画布
offscreenCtx.clearRect(0, 0, canvasWidth, canvasHeight);
// 保存离屏画布状态
offscreenCtx.save();
// 应用平移和缩放(与上层保持一致)
offscreenCtx.translate(offsetX.value, offsetY.value);
offscreenCtx.scale(scale.value, scale.value);
// 在离屏Canvas上绘制背景
drawRealBackgroundToCanvas(offscreenCtx);
// 恢复离屏画布状态
offscreenCtx.restore();
// 更新缓存标记
cachedScale = scale.value;
cachedOffsetX = offsetX.value;
cachedOffsetY = offsetY.value;
}
// 将离屏Canvas内容绘制到显示Canvas
bgCtx.value.clearRect(0, 0, canvasWidth, canvasHeight);
bgCtx.value.drawImage(offscreenCanvas, 0, 0);
};
// 优化将背景绘制逻辑提取为独立函数可在离屏Canvas上绘制
const drawRealBackgroundToCanvas = (targetCtx) => {
targetCtx.strokeStyle = "white";
targetCtx.lineWidth = 0.5;
let _clientHeight = bgCanvasRef.value.clientHeight;
const canvasWidth = _clientHeight * 2;
function strokeRoundRect(ctx, x, y, width, height, radius) {
if (radius <= 0) {
ctx.strokeRect(x, y, width, height);
return;
}
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.arcTo(x + width, y, x + width, y + radius, radius);
ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius);
ctx.arcTo(x, y + height, x, y + height - radius, radius);
ctx.arcTo(x, y, x + radius, y, radius);
ctx.closePath();
ctx.stroke();
}
rects.forEach((d, i) => {
const gap = (canvasWidth * (1 - s_w)) / 2;
let d_imgWidth = (canvasWidth * s_w - 20) / rects.length;
strokeRoundRect(
targetCtx,
gap + (d_imgWidth + 20) * i,
d.y * BL.value,
d_imgWidth,
220 * BL.value,
5
);
if (d.colorBg) {
drawColorBg(
targetCtx,
d.colorBg,
gap + (d_imgWidth + 20) * i,
d.y * BL.value,
d_imgWidth,
220 * BL.value
);
}
if (d.bg) {
targetCtx.drawImage(
d.bg,
gap + (d_imgWidth + 20) * i,
d.y * BL.value,
d_imgWidth,
220 * BL.value
);
}
if (d.label) {
const textMetrics = targetCtx.measureText(d.label).width;
drawText(
targetCtx,
d.label,
gap + d_imgWidth * i + 10 * i + textMetrics / 2,
d.y * BL.value + 10
);
}
});
rects1.forEach((d, i) => {
const gap = (canvasWidth * (1 - s_w)) / 2;
let d_imgWidth = (canvasWidth * s_w - 40) / rects1.length;
strokeRoundRect(
targetCtx,
gap + (d_imgWidth + 20) * i,
d.y * BL.value,
d_imgWidth,
180 * BL.value,
5
);
if (d.colorBg) {
drawColorBg(
targetCtx,
d.colorBg,
gap + d_imgWidth * i + 20 * i,
d.y * BL.value,
d_imgWidth,
180 * BL.value
);
}
if (d.bg) {
targetCtx.drawImage(
d.bg,
gap + d_imgWidth * i + 20 * i,
d.y * BL.value,
d_imgWidth,
180 * BL.value
);
}
if (d.label) {
const textMetrics = targetCtx.measureText(d.label).width;
drawText(
targetCtx,
d.label,
gap + d_imgWidth * i + 10 * i + textMetrics / 2,
d.y * BL.value + 10
);
}
});
rects2.forEach((d, i) => {
const gap = (canvasWidth * (1 - s_w)) / 2;
let d_imgWidth = (canvasWidth * s_w - 40) / rects2.length;
strokeRoundRect(
targetCtx,
gap + (d_imgWidth + 20) * i,
d.y * BL.value,
d_imgWidth,
180 * BL.value,
5
);
if (d.colorBg) {
drawColorBg(
targetCtx,
d.colorBg,
gap + d_imgWidth * i + 20 * i,
d.y * BL.value,
d_imgWidth,
180 * BL.value
);
}
if (d.bg) {
targetCtx.drawImage(
d.bg,
gap + d_imgWidth * i + 20 * i,
d.y * BL.value,
d_imgWidth,
180 * BL.value
);
}
if (d.label) {
const textMetrics = targetCtx.measureText(d.label).width;
drawText(
targetCtx,
d.label,
gap + d_imgWidth * i + 10 * i + textMetrics / 2,
d.y * BL.value + 10
);
}
});
rect_ground.forEach((d, i) => {
const gap = (canvasWidth * (1 - s_w)) / 2;
let d_imgWidth = (canvasWidth * s_w) / rect_ground.length;
strokeRoundRect(
targetCtx,
gap + (d_imgWidth + 20) * i,
d.y * BL.value,
d_imgWidth,
180 * BL.value,
5
);
if (d.colorBg) {
drawColorBg(
targetCtx,
d.colorBg,
gap + d_imgWidth * i + 20 * i,
d.y * BL.value,
d_imgWidth,
180 * BL.value
);
}
if (d.bg) {
targetCtx.drawImage(
d.bg,
gap + d_imgWidth * i + 20 * i,
d.y * BL.value,
d_imgWidth,
180 * BL.value
);
}
if (d.label) {
const textMetrics = targetCtx.measureText(d.label).width;
drawText(
targetCtx,
d.label,
gap + d_imgWidth * i + 10 * i + textMetrics / 2,
d.y * BL.value + 10
);
}
});
};
// 优化初始化Canvas同时初始化两层Canvas和离屏Canvas
const initCanvas = () => {
nextTick(() => {
// 初始化上层主Canvas
ctx.value = canvasRef.value.getContext("2d");
// 优化:启用图像平滑以获得更好的缩放质量
if (ctx.value) {
ctx.value.imageSmoothingEnabled = true;
ctx.value.imageSmoothingQuality = "high";
}
// 初始化底层背景Canvas
bgCtx.value = bgCanvasRef.value.getContext("2d");
// 优化:启用图像平滑
if (bgCtx.value) {
bgCtx.value.imageSmoothingEnabled = true;
bgCtx.value.imageSmoothingQuality = "high";
}
// 初始化离屏Canvas
initOffscreenCanvas();
// 加载背景图(加载完成后绘制底层背景)
loadImage();
let _clientHeight = bgCanvasRef.value.clientHeight;
const _w = _clientHeight * 2;
let _left = (bgCanvasRef.value.clientWidth - _w) / 2;
offsetX.value = _left;
});
};
const getIcon = (id) => {
let _d = shiyan_typesDictionary.filter((d) => {
return d.value == id;
});
if (_d && _d[0]) {
return _d[0].img;
} else {
return "";
}
};
// 加载背景图修改加载完成后调用drawBackground
const loadImage = async () => {
// 等待所有图片加载完成后执行操作
const bitmaps = await loadAllImages(imagePaths);
if (bitmaps.length === 9) {
loading.value = false;
rects[0].bg = bitmaps[6];
rects[1].bg = bitmaps[7];
rects1[0].bg = bitmaps[1];
rects1[1].bg = bitmaps[3];
rects1[2].bg = bitmaps[5];
rects2[0].bg = bitmaps[0];
rects2[1].bg = bitmaps[2];
rects2[2].bg = bitmaps[4];
rect_ground[0].bg = bitmaps[8];
nextTick(() => {
resizeCanvas();
// 首次绘制背景
throttledReBGdraw();
});
}
};
// 优化调整Canvas大小重置缓存
const resizeCanvas = () => {
if (!canvasRef.value || !bgCanvasRef.value) return;
// 同时设置两层Canvas的大小保持一致
const clientWidth = canvasRef.value.clientWidth;
const clientHeight = canvasRef.value.clientHeight;
canvasRef.value.width = clientWidth;
canvasRef.value.height = clientHeight;
bgCanvasRef.value.width = clientWidth;
bgCanvasRef.value.height = clientHeight;
BL.value = clientHeight / StandardC;
// 重置缓存因为Canvas尺寸改变
cachedScale = null;
cachedOffsetX = null;
cachedOffsetY = null;
initOffscreenCanvas();
// 窗口 resize 时需要重新绘制背景
throttledReBGdraw();
// 同时重绘上层内容
throttledRedraw();
};
// 绘制tooltip保持不变
const drawTooltip = () => {
if (!activeMarker.value || !activeMarker.value.target) return;
const ctxTooltip = ctx.value;
const text =
activeMarker.value.target === "camera"
? `${activeMarker.value.CamName} - ${activeMarker.value.CamAddress}`
: `${activeMarker.value.DeviceName} - ${activeMarker.value.AreaName} - ${activeMarker.value.TypeName}`;
const lines = text.split(" - ");
const padding = 10;
const lineHeight = 18;
const maxWidth = 300;
ctxTooltip.font = "14px Arial";
let tooltipWidth = 0;
lines.forEach((line) => {
const width = ctxTooltip.measureText(line).width;
if (width > tooltipWidth) tooltipWidth = Math.min(width, maxWidth);
});
tooltipWidth += padding * 2;
const tooltipHeight = lines.length * lineHeight + padding * 2;
const canvasX = activeMarker.value.x * BL.value * scale.value + offsetX.value;
const canvasY = activeMarker.value.y * BL.value * scale.value + offsetY.value;
let x = canvasX - tooltipWidth / 2;
let y = canvasY - tooltipHeight - 6 * scale.value;
const canvasWidth = canvasRef.value.width;
if (x < 5) x = 5;
if (x + tooltipWidth > canvasWidth - 5) x = canvasWidth - tooltipWidth - 5;
if (y < 5) {
y = canvasY + 20
} else {
y = canvasY - 90
}
ctxTooltip.save();
ctxTooltip.fillStyle = "rgba(0, 20, 40, 0.95)";
ctxTooltip.strokeStyle = "#4dabf7";
ctxTooltip.lineWidth = 1;
const radius = 10;
ctxTooltip.beginPath();
ctxTooltip.moveTo(x + radius, y);
ctxTooltip.lineTo(x + tooltipWidth - radius, y);
ctxTooltip.arcTo(x + tooltipWidth, y, x + tooltipWidth, y + radius, radius);
ctxTooltip.lineTo(x + tooltipWidth, y + tooltipHeight - radius);
ctxTooltip.arcTo(
x + tooltipWidth,
y + tooltipHeight,
x + tooltipWidth - radius,
y + tooltipHeight,
radius
);
ctxTooltip.lineTo(x + radius, y + tooltipHeight);
ctxTooltip.arcTo(x, y + tooltipHeight, x, y + tooltipHeight - radius, radius);
ctxTooltip.lineTo(x, y + radius);
ctxTooltip.arcTo(x, y, x + radius, y, radius);
ctxTooltip.closePath();
ctxTooltip.fill();
ctxTooltip.stroke();
ctxTooltip.fillStyle = "#e0f7ff";
ctxTooltip.font = "14px Arial";
ctxTooltip.textBaseline = "middle";
lines.forEach((line, index) => {
let currentLine = "";
let currentY = y + padding + lineHeight / 2 + index * lineHeight;
for (let i = 0; i < line.length; i++) {
const testLine = currentLine + line[i];
const metrics = ctxTooltip.measureText(testLine);
if (metrics.width > maxWidth && i > 0) {
ctxTooltip.fillText(currentLine, x + padding, currentY);
currentLine = line[i];
currentY += lineHeight;
} else {
currentLine = testLine;
}
}
ctxTooltip.fillText(currentLine, x + padding, currentY);
});
ctxTooltip.restore();
};
// 绘制函数(修改:移除背景图绘制,只绘制动态内容)
const draw = () => {
if (!ctx.value || !img.complete) return;
const canvasWidth = canvasRef.value.width;
const canvasHeight = canvasRef.value.height;
BL.value = canvasHeight / StandardC;
// 清除上层画布(只清除动态内容)
ctx.value.clearRect(0, 0, canvasWidth, canvasHeight);
// 保存上层画布状态
ctx.value.save();
// 应用平移和缩放(与底层保持一致)
ctx.value.translate(offsetX.value, offsetY.value);
ctx.value.scale(scale.value, scale.value);
// 绘制参考线(编辑状态时)
if (isEdit.value && activeReferenceLine.value !== null) {
ctx.value.beginPath();
ctx.value.lineTo(imgWidth.value, activeReferenceLine.value * BL.value);
ctx.value.strokeStyle = "rgba(35, 139, 34, 0.9)";
ctx.value.lineWidth = 1;
ctx.value.stroke();
ctx.value.beginPath();
ctx.value.rect(
0,
(activeReferenceLine.value - snapThreshold) * BL.value,
imgWidth.value,
snapThreshold * 2 * BL.value
);
ctx.value.fillStyle = "rgba(35, 139, 34, 0.4)";
ctx.value.fill();
}
// 绘制点标记(动态内容)
points.forEach((point) => {
if (SELECT_ACTION_TYPE.value && point.TypeId !== SELECT_ACTION_TYPE.value) {
return;
}
const size = pointSize.value * scale.value * 0.2;
const iSize = iconSize.value * scale.value * 0.2;
ctx.value.beginPath();
ctx.value.arc(
point.x * BL.value,
point.y * BL.value,
(size * BL.value) / scale.value,
0,
Math.PI * 2
);
ctx.value.fillStyle = "#FFFFFF";
ctx.value.fill();
let _img = point.target == "device" ? getIcon(point.TypeId) : "camera";
if (_img && icons[_img]) {
ctx.value.drawImage(
icons[_img],
point.x * BL.value - (iSize * BL.value) / scale.value / 2,
point.y * BL.value - (iSize * BL.value) / scale.value / 2,
(iSize * BL.value) / scale.value,
(iSize * BL.value) / scale.value
);
}
// 辅助函数:绘制圆角矩形
function roundedRect(ctx, x, y, width, height, radius, isStroke = false) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.arcTo(x + width, y, x + width, y + radius, radius);
ctx.lineTo(x + width, y + height - radius);
ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius);
ctx.lineTo(x + radius, y + height);
ctx.arcTo(x, y + height, x, y + height - radius, radius);
ctx.lineTo(x, y + radius);
ctx.arcTo(x, y, x + radius, y, radius);
ctx.closePath();
if (isStroke) {
ctx.stroke();
} else {
ctx.fill();
}
}
// 添加文字传感器数值带圆角边框和半透明背景当缩放倍率大于20时出现
if (
point.TypeName.includes("传感器") === true &&
point.Data &&
point.Data.length > 0 && scale.value > 20
) {
// 设置字体样式
ctx.value.font = `0.5px Arial`;
const textColor = "#FFF"; // 白色字体
const borderColor = "rgba(100, 180, 255, 0.9)"; // 浅蓝色边框
const bgColor = "rgba(100, 180, 255, 0.4)"; // 同色系半透明背景
// 生成所有传感器数据文本数组
const textLines = point.Data.map((item) => {
const pointName = item.IotAgreementHostPoint.point_name || "";
const unit = item.IotAgreementHostPoint.unit || "";
let status =
item.IotAgreementHostPoint.status !== undefined
? item.IotAgreementHostPoint.status
: "";
// 当 point.TypeId == 71 时,需要特殊计算
if ((point.TypeId == 71 || point.TypeId == 69) && status !== "" && status !== undefined && status !== null) {
const numValue = Number(status);
if (numValue === 0) {
status = 0;
} else {
// 实际数值 = (status - 400) / 1600 * 5保留1位小数
const calculatedValue = ((numValue - 400) / 1600) * 5;
status = calculatedValue.toFixed(1);
}
}
if(point.TypeId == 86 || point.TypeId == 84 || point.TypeId == 81) {
const numValue = Number(status);
if (numValue === 0) {
status = 0;
} else {
status = numValue.toFixed(1);
}
}
return `${pointName} : ${status} ${unit}`;
});
// 计算文字位置图标右侧10px处垂直居中对齐
const textX = point.x * BL.value;
const textY = point.y * BL.value ; // 垂直居中调整
const padding = 0.2; // 文字周围的内边距
const borderRadius = 0.5; // 圆角半径
const lineHeight = 0.7; // 行高
const lineSpacing = 0.1; // 行间距
// 测量所有文本的宽度,找出最宽的一行
let maxWidth = 0;
textLines.forEach((line) => {
const textMetrics = ctx.value.measureText(line);
if (textMetrics.width > maxWidth) {
maxWidth = textMetrics.width;
}
});
// 计算背景框的位置和大小
const bgX = textX - padding - maxWidth / 2;
const bgY = textY - 6.5; // 基于10px字体的位置调整
const bgWidth = maxWidth + padding * 2;
const bgHeight =
textLines.length * (lineHeight + lineSpacing) -
lineSpacing +
padding * 2;
// 绘制圆角背景
ctx.value.fillStyle = bgColor;
roundedRect(ctx.value, bgX, bgY + 7, bgWidth, bgHeight, borderRadius);
// 绘制圆角边框
ctx.value.strokeStyle = borderColor;
ctx.value.lineWidth = 0.1;
roundedRect(
ctx.value,
bgX,
bgY + 7,
bgWidth,
bgHeight,
borderRadius,
true
);
// 绘制所有文字行(确保在最上层)
ctx.value.fillStyle = textColor;
textLines.forEach((line, index) => {
const lineMetrics = ctx.value.measureText(line);
const lineY =
textY + 3 + padding + index * (lineHeight + lineSpacing) - 2;
ctx.value.fillText(line, textX - lineMetrics.width / 2, lineY);
});
}
// if (point.target == "device" && point.IsOpen) {
// ctx.value.strokeStyle = window.customConfigUrl.openColor;
// } else if (point.target == "device" && point.IsAlarm) {
// ctx.value.strokeStyle = window.customConfigUrl.faultColor;
// } else if (point.target == "device" && point.IsFault) {
// ctx.value.strokeStyle = window.customConfigUrl.faultColor;
// }
ctx.value.shadowBlur = 0;
});
// 恢复上层画布状态
ctx.value.restore();
// 绘制tooltip上层最顶层
if (activeMarker.value && activeMarker.value.target) {
drawTooltip();
cursorStyle.value = "pointer";
} else {
cursorStyle.value = "";
}
};
// 动画循环(保持不变)
const animate = () => {
const time = Date.now() / 1000;
throttledRedraw();
requestAnimationFrame(animate);
};
// 鼠标移动处理保持不变只影响上层Canvas
let mouseMoveTimeout = null;
const onMouseMove = (e) => {
if (!BL.value) return;
if (dragging.value === null) {
if (mouseMoveTimeout) {
cancelAnimationFrame(mouseMoveTimeout);
}
mouseMoveTimeout = requestAnimationFrame(() => {
handleMouseMove(e);
});
return;
}
isDragging.value = true;
const rect = canvasRef.value.getBoundingClientRect();
mouseX.value = Math.floor(e.clientX - rect.left);
mouseY.value = Math.floor(e.clientY - rect.top);
const deltaX = mouseX.value - startDragX.value;
const deltaY = mouseY.value - startDragY.value;
if (dragging.value === "background") {
offsetX.value = e.clientX - startX.value;
offsetY.value = e.clientY - startY.value;
// 拖动背景时,标记为非缩放状态,正常更新底层背景
if (isZooming.value) {
isZooming.value = false;
if (zoomEndTimer) {
clearTimeout(zoomEndTimer);
zoomEndTimer = null;
}
}
throttledReBGdraw();
} else {
const originalXDelta = deltaX / scale.value;
const originalYDelta = deltaY / scale.value;
const newOriginalX = startPointX.value + originalXDelta;
const newOriginalY = startPointY.value + originalYDelta;
currentPoint.x = newOriginalX / BL.value;
currentPoint.y = newOriginalY / BL.value;
// currentPointX.value = currentPoint.x;
// currentPointY.value = currentPoint.y;
let closestLine = null;
let minDistance = Infinity;
referenceLines.forEach((lineY) => {
const distance = Math.abs(currentPoint.y - lineY);
if (distance < snapThreshold && distance < minDistance) {
minDistance = distance;
closestLine = lineY;
}
});
activeReferenceLine.value = closestLine;
if (closestLine !== null) {
const easingFactor = 0.2;
currentPoint.y =
currentPoint.y + (closestLine - currentPoint.y) * easingFactor;
if (Math.abs(currentPoint.y - closestLine) < 10) {
currentPoint.y = closestLine;
}
}
}
throttledRedraw(); // 只重绘上层动态内容
};
const handleMouseMove = (e) => {
const rect = canvasRef.value.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const originalX = (x - offsetX.value) / scale.value;
const originalY = (y - offsetY.value) / scale.value;
const mouseMoved =
Math.abs(x - lastMousePosition.value.x) > 2 ||
Math.abs(y - lastMousePosition.value.y) > 2;
if (!mouseMoved && activeMarker.value.target) {
return;
}
lastMousePosition.value = { x, y };
let newActiveMarker = { target: "" };
points.forEach((point) => {
// 使用距离计算而不是路径检测,更精确且不受 scale 影响
if (point.TypeId == SELECT_ACTION_TYPE.value || SELECT_ACTION_TYPE.value == "") {
const pointX = point.x * BL.value;
const pointY = point.y * BL.value;
// 计算检测半径与绘制时保持一致pointSize * 0.2 * BL
// 绘制时半径是 (pointSize * scale * 0.2 * BL) / scale = pointSize * 0.2 * BL
const detectRadius = pointSize.value * 0.2 * BL.value;
const distance = Math.sqrt(
Math.pow(originalX - pointX, 2) + Math.pow(originalY - pointY, 2)
);
if (distance <= detectRadius) {
newActiveMarker = point;
}
}
});
if (
newActiveMarker.target !== activeMarker.value.target ||
newActiveMarker.DeviceId !== activeMarker.value.DeviceId
) {
activeMarker.value = newActiveMarker;
throttledRedraw(); // 只重绘上层动态内容
}
};
const handleMouseOut = () => {
if (activeMarker.value.target) {
activeMarker.value = { target: "" };
throttledRedraw(); // 只重绘上层动态内容
}
};
// 开始拖拽
const startDrag = (e) => {
const rect = canvasRef.value.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const originalX = (x - offsetX.value) / scale.value;
const originalY = (y - offsetY.value) / scale.value;
if (isEdit.value) {
points.forEach((point) => {
if (point.TypeId == SELECT_ACTION_TYPE.value || SELECT_ACTION_TYPE.value == "") {
const pointX = point.x * BL.value;
const pointY = point.y * BL.value;
// 计算检测半径与绘制时保持一致pointSize * 0.2 * BL
const detectRadius = pointSize.value * 0.2 * BL.value;
const distance = Math.sqrt(
Math.pow(originalX - pointX, 2) + Math.pow(originalY - pointY, 2)
);
if (distance <= detectRadius) {
dragging.value = "point";
currentPoint = point;
startPointX.value = point.x * BL.value;
startPointY.value = point.y * BL.value;
startDragX.value = mouseX;
startDragY.value = mouseY;
return;
}
}
});
}
if(dragging.value == "point") {
return
}
dragging.value = "background";
startX.value = e.clientX - offsetX.value;
startY.value = e.clientY - offsetY.value;
};
const mouseUp = () => {
endDrag(1);
};
const mouseLeave = () => {
endDrag(2);
};
const endDrag = (type) => {
if (type == 1 && dragging.value == "point") {
// 拖拽图标结束时 可以调用接口上传坐标信息
updateDevice(currentPoint.DeviceId, {
coordinate_x: currentPoint.x,
coordinate_y: currentPoint.y,
});
}
dragging.value = null;
handleMouseOut();
if (currentPoint.path) {
currentPoint.path = new Path2D();
currentPoint.path.arc(
currentPoint.x * BL.value,
currentPoint.y * BL.value,
pointSize.value * BL.value,
0,
2 * Math.PI
);
currentPoint.path.closePath();
}
};
const handleProgressChange2 = (e) => {
selectAreaId.value = e.area;
scale.value = 2;
centerPoint(e);
};
// 优化:处理滚轮缩放(缩放过程中降低背景重绘频率)
const handleWheel = (e) => {
e.preventDefault();
const rect = canvasRef.value.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const imgX = (mouseX - offsetX.value) / scale.value;
const imgY = (mouseY - offsetY.value) / scale.value;
const zoomIntensity = 1;
const wheel = e.deltaY < 0 ? 1 : -1;
const newScale = Math.max(
0.75,
Math.min(25, scale.value + wheel * zoomIntensity)
);
offsetX.value = mouseX - imgX * newScale;
offsetY.value = mouseY - imgY * newScale;
scale.value = newScale;
// 标记正在缩放
isZooming.value = true;
// 清除之前的定时器
if (zoomEndTimer) {
clearTimeout(zoomEndTimer);
}
// 缩放过程中:使用低频率背景重绘,优先重绘上层内容
throttledReBGdrawDuringZoom();
throttledRedraw();
// 缩放结束后,延迟一定时间再完整重绘背景
zoomEndTimer = setTimeout(() => {
isZooming.value = false;
// 缩放结束后完整重绘背景
drawBackground();
throttledRedraw();
}, 150); // 150ms后认为缩放结束
};
// 处理Canvas点击
const handleCanvasClick = (e) => {
isDragging.value = false;
const rect = canvasRef.value.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const originalX = (x - offsetX.value) / scale.value;
const originalY = (y - offsetY.value) / scale.value;
console.log(
`点击坐标: (${(originalX / BL.value).toFixed(1)}, ${(
originalY / BL.value
).toFixed(1)})`
);
let clickedPoint = false;
points.forEach((point) => {
if (point.TypeId == SELECT_ACTION_TYPE.value || SELECT_ACTION_TYPE.value == "") {
const pointX = point.x * BL.value;
const pointY = point.y * BL.value;
// 计算检测半径与绘制时保持一致pointSize * 0.2 * BL
const detectRadius = pointSize.value * 0.2 * BL.value;
const distance = Math.sqrt(
Math.pow(originalX - pointX, 2) + Math.pow(originalY - pointY, 2)
);
if (distance <= detectRadius) {
clickedPoint = true;
if (point.target == "camera") {
selectPoint(point);
selectedPointId.value = null;
} else {
// currentPointX.value = point.x;
// currentPointY.value = point.y;
selectPoint(point);
}
}
}
});
if (!clickedPoint) {
selectedPointId.value = null;
throttledRedraw(); // 只重绘上层动态内容
}
};
// 重置视图(修改:重置时更新底层背景)
const resetView = () => {
scale.value = 1;
offsetX.value = 0;
offsetY.value = 0;
selectedPointId.value = null;
progress.value = 0;
selectAreaId.value = 1;
initCanvas();
};
// 注册引导前的准备工作(同时注册 /plan 和 /index 路径)
const prepareGuide = () => {
resetView();
// 确保设备类型列表可见
if (!showFlag.value) {
showFlag.value = true;
}
return nextTick();
};
registerPagePrepare("/shiyan-plan", prepareGuide);
// 选择点
const selectPoint = (data) => {
let some = dialogList.filter((d) => {
if (d.target === "camera") {
return d.id == data.CamAddress;
} else {
return d.id == data.DeviceId;
}
});
if (some && some.length) {
some[0].show = true;
return;
}
let max_index = 1100;
dialogList.forEach((d) => {
if (d.index > max_index) {
max_index = d.index;
}
});
dialogList.push({
show: true,
id: data.target == "device" ? data.DeviceId : data.CamAddress,
TypeId: data.TypeId,
AreaId: data.AreaId,
TypeName: data.TypeName,
index: max_index + 1,
type: data.TypeId,
extra: data,
target: data.target,
info: data.target == "device" ? "" : data,
});
};
// 点居中显示(修改:居中时更新底层背景)
const centerPoint = (point) => {
if (!point || !canvasRef.value) return;
const targetOffsetX = 0 - point.x * BL.value * scale.value;
const targetOffsetY = 0 - point.y * BL.value * scale.value;
const startOffsetX = offsetX.value;
const startOffsetY = offsetY.value;
const duration = 800;
const startTime = Date.now();
isAnimating.value = true;
const translateAnimate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const ease = 1 - Math.pow(1 - progress, 3);
offsetX.value = startOffsetX + (targetOffsetX - startOffsetX) * ease;
offsetY.value = startOffsetY + (targetOffsetY - startOffsetY) * ease;
// 动画过程中更新背景和上层内容
// 动画时降低背景重绘频率以提高性能
if (progress % 0.1 < 0.05) {
// 每10%进度重绘一次背景
throttledReBGdraw();
}
throttledRedraw();
if (progress < 1) {
requestAnimationFrame(translateAnimate);
} else {
isAnimating.value = false;
// 动画结束后完整重绘
drawBackground();
throttledRedraw();
setTimeout(() => {
throttledRedraw();
}, 1200);
}
};
requestAnimationFrame(translateAnimate);
};
// 关闭对话框
const closeDialog = (e) => {
dialogList[e].show = false;
let hasshow = dialogList.filter((d) => {
return d.show === true;
});
if (!hasshow || !hasshow.length) {
dialogList.forEach((d, i) => {
dialogList.splice(i, 1);
});
}
};
const openCameraDialog = (equipment) => {
let point = points.filter((d) => {
return d.target === "camera" && d.CamId == equipment.Remark6;
});
if (point && point[0]) {
selectPoint(point[0]);
}
};
const upDialogZindex = (e) => {
let max_index = 0;
dialogList.forEach((d) => {
if (d.index > max_index) {
max_index = d.index;
}
});
dialogList[e].index = max_index + 1;
};
// MQTT 相关
const mqttClientRef = ref(null);
// 全局事件监听器
let pointValueUpdateHandler = null;
// 通过 provide 将 MQTT 实例提供给子组件
provide("mqttClient", mqttClientRef);
// 初始化全局事件监听
const initPointValueListener = () => {
// 使用全局的 MQTT 客户端(在 App.vue 中已初始化)
if (window.mqttClient) {
mqttClientRef.value = window.mqttClient;
}
// 监听全局点位更新事件
pointValueUpdateHandler = (event) => {
const { pointId, status } = event.detail;
const _id = pointId;
const data = status;
// 循环 points 找出一项下面 Points 中包含 _id 的那一项
const matchedPoint = points.find((point) => {
if (!point.Points || !Array.isArray(point.Points)) {
return false;
}
// 支持字符串和数字类型的 id 匹配
let isMatched = point.Points.some(
(pointId) => String(pointId) === String(_id)
);
if(isMatched) {
point.Data.forEach((d)=> {
if(d.IotAgreementHostPoint.id == _id) {
d.status = data;
// 同时更新 IotAgreementHostPoint 中的 status确保模板能正确显示
if (d.IotAgreementHostPoint) {
d.IotAgreementHostPoint.status = data;
}
}
})
throttledRedraw(); // 只重绘上层动态内容
}
});
if (matchedPoint) {
matchedPoint.Data.forEach((d)=> {
if(d.IotAgreementHostPoint.id === _id) {
d.status = data;
// 同时更新 IotAgreementHostPoint 中的 status确保模板能正确显示
if (d.IotAgreementHostPoint) {
d.IotAgreementHostPoint.status = data;
}
}
})
// 同步更新 dialogList 中对应 dialogEle 的数据
dialogList.forEach((dialogItem) => {
if (dialogItem.target === "device" && dialogItem.id === matchedPoint.DeviceId) {
// 更新 extra 中的数据
if (dialogItem.extra && dialogItem.extra.Data) {
dialogItem.extra.Data.forEach((d) => {
if (d.IotAgreementHostPoint && d.IotAgreementHostPoint.id === _id) {
d.status = data;
// 同时更新 IotAgreementHostPoint 中的 status
d.IotAgreementHostPoint.status = data;
}
});
}
}
});
}
};
window.addEventListener('pointValueUpdate', pointValueUpdateHandler);
console.log("已注册全局点位更新事件监听器");
};
// 初始化
onMounted(() => {
// getJson();
initCanvas();
getAreaFun();
getDeviceTypeFun();
getDevicesFun();
// initChart();
window.addEventListener("resize", resizeCanvas);
// 初始化全局事件监听
initPointValueListener();
});
onUnmounted(() => {
if (mouseMoveTimeout) {
cancelAnimationFrame(mouseMoveTimeout);
}
if (zoomEndTimer) {
clearTimeout(zoomEndTimer);
}
window.removeEventListener("resize", resizeCanvas);
// 清理离屏Canvas
if (offscreenCanvas) {
offscreenCanvas = null;
offscreenCtx = null;
}
// 取消注册页面准备工作
unregisterPagePrepare("/plan");
unregisterPagePrepare("/shiyan-plan");
// 清理全局事件监听器
if (pointValueUpdateHandler) {
try {
window.removeEventListener('pointValueUpdate', pointValueUpdateHandler);
pointValueUpdateHandler = null;
console.log("已取消全局点位更新事件监听器");
} catch (error) {
console.error("取消全局事件监听器失败:", error);
}
}
});
</script>
<style lang="less" scoped>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
#app {
position: relative;
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a2a6c, #b21f1f50, #1a2a6c);
color: #fff;
min-height: 100vh;
overflow-x: hidden;
}
.container {
width: 100%;
height: calc(100vh - 84px);
margin: 0;
}
.app-container {
width: 100%;
height: 100%;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.tabbar-content {
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-50%);
z-index: 99;
display: flex;
flex-wrap: wrap;
font-size: 14px;
flex-direction: column;
background: rgba(0, 0, 0, 0.1);
backdrop-filter: blur(4px);
border-radius: 4px;
padding: 10px;
}
.tabbar-item {
margin-bottom: 2px;
cursor: pointer;
line-height: 20px;
display: flex;
padding: 2px 4px;
border-radius: 4px;
color: #fff;
&-img {
width: 20px;
height: 20px;
background: #fff;
border-radius: 50%;
margin-right: 4px;
padding: 2px;
}
img {
width: 16px;
height: 16px;
}
}
.tabbar-item.show {
background: rgba(255, 255, 255, 0.5);
}
.action-bar {
width: 100px;
height: 40px;
position: absolute;
right: 30px;
top: 30px;
z-index: 99;
display: flex;
justify-content: space-between;
&-item {
width: 30px;
height: 30px;
cursor: pointer;
border-radius: 50%;
img {
width: 30px;
height: 30px;
}
}
}
.spicon {
background: #fff;
img {
width: 20px;
height: 20px;
margin-left: 5px;
margin-top: 5px;
}
}
canvas {
transform: translateZ(0); /* 触发GPU加速 */
will-change: transform; /* 提示浏览器该元素可能会动画,提前优化 */
}
.canvas-container {
flex: 1;
min-width: 300px;
height: 100%;
background: rgba(0, 0, 0, 0.25);
border-radius: 15px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
border: 0;
padding: 10px;
box-sizing: border-box;
position: relative;
}
.canvas-wrapper {
position: relative;
flex: 1;
border-radius: 10px;
overflow: hidden;
background: #000;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.4);
height: 100%;
}
/* 新增两层Canvas样式确保完全重叠 */
.bg-canvas,
.main-canvas {
display: block;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
/* 底层背景Canvas层级更低上层主Canvas层级更高用于交互 */
.bg-canvas {
z-index: 1;
background: #222;
}
.main-canvas {
z-index: 2;
transition: transform 0.3s ease;
}
.reset-btn {
margin: 0;
}
button {
display: block;
width: 100%;
padding: 12px;
background: linear-gradient(to right, #ff9966, #ff5e62);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 10px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
}
button:active {
transform: translateY(1px);
}
@keyframes center-point {
0% {
transform: scale(1);
}
50% {
transform: scale(1.3);
}
100% {
transform: scale(1);
}
}
@media (max-width: 768px) {
.app-container {
flex-direction: column;
}
}
.smooth-transition {
transition: transform 0.8s cubic-bezier(0.22, 0.61, 0.36, 1);
}
::v-deep .el-dialog {
backdrop-filter: blur(10px) !important;
background: #fff8dc;
padding: 0;
border-radius: 6px;
overflow: hidden;
backdrop-filter: blur(4px);
}
::v-deep .el-dialog__header {
background: transparent !important;
box-shadow: none !important;
text-align: left;
line-height: 20px;
padding: 0;
}
::v-deep .el-dialog__headerbtn {
background: transparent !important;
box-shadow: none !important;
line-height: 20px;
padding: 15px !important;
height: 20px !important;
width: 20px !important;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
padding: 15px;
}
.progress-container {
width: 90%;
height: 48px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(4px);
border-radius: 4px;
position: absolute;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
box-shadow: 0 4px 10px rgba(255, 255, 255, 0.1);
display: flex;
padding: 4px 0;
}
.spslider {
bottom: 70px;
}
.bar-content {
width: 380px;
height: 40px;
position: absolute;
z-index: 1;
top: 3px;
left: 10px;
}
.progress-bar {
width: 100%;
}
.charts-content {
width: 800px;
height: 240px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(4px);
border-radius: 4px;
position: absolute;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
box-shadow: 0 4px 10px rgba(255, 255, 255, 0.1);
display: flex;
.line-chart {
width: 100%;
height: 100%;
}
}
/deep/ .el-slider__button {
height: 10px !important;
width: 10px !important;
}
.slider-content {
width: 100%;
display: flex;
left: 0;
justify-content: space-around;
}
.slider-item {
width: 10%;
background: #00000050;
font-size: 14px;
text-align: center;
line-height: 40px;
border-radius: 4px;
cursor: pointer;
color: #fff;
}
.is-select-area {
background: rgba(255, 255, 255, 0.5);
}
.current-icon-info {
position: absolute;
bottom: 20px;
left: 20px;
background: rgba(255, 255, 255, 0.2);
height: 30px;
z-index: 9999;
border-radius: 4px;
padding: 0 10px;
span {
margin-right: 20px;
line-height: 30px;
}
}
</style>