2503 lines
63 KiB
Vue
2503 lines
63 KiB
Vue
<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>
|
||
|