KonvaMap.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  1. <template>
  2. <div class="konva-map">
  3. <div class="map-container">
  4. <div v-moveable:1 v-if="!isKnovaDestroy">
  5. <v-stage ref="stageAll" :config="stageConfig" @click="handleStageClick">
  6. <v-layer ref="layer">
  7. <v-image :config="bgConfig" v-show="bgImgUrl" />
  8. <v-group
  9. v-for="camera in cameras"
  10. :key="camera.id"
  11. :id="camera.id"
  12. :config="camera.groupConfig"
  13. @click="handleCameraClick(camera)"
  14. @mouseover="(e) => handleMouseOver(e)"
  15. @mouseleave="handleMouseLeave()"
  16. @dragstart="handleDragStart()"
  17. >
  18. <v-image :config="camera.config" />
  19. <v-image v-if="camera.isDefault" ref="defaultIcon" :config="defaultIconConfig" />
  20. </v-group>
  21. <v-transformer :config="transformerConfig" ref="transformer" />
  22. </v-layer>
  23. </v-stage>
  24. <div
  25. v-show="defaultShow"
  26. class="opt-container"
  27. :style="{ position: 'absolute', left: posX + 'px', top: posY + 'px' }"
  28. >
  29. <div class="opt-item" :class="{ disabled: disabledSet }" @click="setDefaultCamera">设为默认相机</div>
  30. <div class="opt-item" @click="previewCamera">预览相机</div>
  31. <div class="opt-item" @click="handleDeleteCamera">删除标签</div>
  32. </div>
  33. <CameraPreview
  34. v-if="isShow"
  35. :last-pos-x="posX!"
  36. :last-pos-y="posY!"
  37. :video-url="videoUrl"
  38. @close="closePreview"
  39. />
  40. <DefaultTip
  41. v-show="selectCameraId"
  42. :position="pos"
  43. :is-default="isDefaultCamera"
  44. :camera-info="cameraInfo"
  45. :style="{ position: 'absolute', left: posTipX + 'px', top: posTipY + 'px' }"
  46. />
  47. </div>
  48. </div>
  49. </div>
  50. </template>
  51. <script setup lang="ts">
  52. import { ref, onMounted, watch, computed } from 'vue';
  53. import { ElMessage } from 'element-plus';
  54. import DefaultTip from '../components/DefaultTip.vue';
  55. import cameraImgSrc from '@/assets/camera/cameraImg.png';
  56. import favoritesImgSrc from '@/assets/camera/favorites.png';
  57. import { TipPositionEnum, camerasGroupType, cameraInfoType } from '../type';
  58. import { cloneDeep } from 'lodash-es';
  59. import { updateMinMapViewLayoutApi } from '@/api/scene/scene';
  60. import CameraPreview from './CameraPreview.vue';
  61. import { ShopMapCamera } from '@/types/scene/type';
  62. import { openMessageBox } from '@/views/system-config/business-scene/components/MessageBox';
  63. const emit = defineEmits(['changeDefaultCamera', 'sendCameraId', 'change']);
  64. interface MapConfigType {
  65. width: number;
  66. height: number;
  67. }
  68. const props = defineProps<{
  69. filterData: ShopMapCamera[];
  70. cameraList: ShopMapCamera[];
  71. mapConfig: MapConfigType;
  72. isKnovaDestroy: boolean;
  73. }>();
  74. const transformerScale = computed(() => {
  75. if (stageConfig.value.width === 0 || stageConfig.value.height === 0) return 1;
  76. return Math.min(props.mapConfig.width / stageConfig.value.width, props.mapConfig.height / stageConfig.value.height);
  77. });
  78. const camImg = new Image();
  79. const stageConfig = ref({
  80. width: 0,
  81. height: 0,
  82. });
  83. const bgImg = new Image();
  84. const favoritesImg = new Image();
  85. favoritesImg.src = favoritesImgSrc;
  86. //右键点击是否出现
  87. const defaultShow = ref<boolean>(false);
  88. const posX = ref<number>();
  89. const posY = ref<number>();
  90. const posTipX = ref<number>();
  91. const posTipY = ref<number>();
  92. const pos = ref(TipPositionEnum.TOP);
  93. //上一次点击的相机
  94. const lastClickedGroupId = ref<string | null>(null);
  95. const lastClickedVideoUrl = ref<string | null>(null);
  96. const bgImgUrl = ref<string | null>('');
  97. const cameraInfo = ref<cameraInfoType>();
  98. const cameras = ref<camerasGroupType[]>([]);
  99. //默认相机id
  100. const defaultCameraId = ref('');
  101. const isDefaultCamera = ref(false);
  102. const disabledSet = ref(false);
  103. const videoUrl = ref<string>('');
  104. const cameraIconSize = { width: 52, height: 52 };
  105. //标签
  106. const layer = ref();
  107. const transformer = ref();
  108. const defaultIcon = ref();
  109. function addIntegrationState(data: camerasGroupType[], updateData: ShopMapCamera[]) {
  110. for (let i = 0; i < data.length; i++) {
  111. const camera = data[i];
  112. const matchedCamera = updateData.find((item) => item.code === camera.id);
  113. if (matchedCamera) {
  114. camera.groupConfig.visible = matchedCamera.integrationState === 0 ? true : false;
  115. }
  116. }
  117. }
  118. const bgConfig = ref({
  119. x: 0,
  120. y: 0,
  121. id: 'bgImg',
  122. width: bgImg.width,
  123. height: bgImg.height,
  124. image: bgImg,
  125. name: 'bgImg',
  126. backgroundSize: 'cover',
  127. });
  128. //待修改
  129. const transformerConfig = ref({
  130. keepRatio: true,
  131. rotateAnchorOffset: 30,
  132. enabledAnchors: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
  133. });
  134. //背景大小变换
  135. const resizeContainer = (width, height) => {
  136. stageConfig.value.width = width;
  137. stageConfig.value.height = height;
  138. // layer.value.getNode().draw();
  139. };
  140. const defaultIconConfig = ref({
  141. x: 18,
  142. y: -16,
  143. width: 16,
  144. height: 16,
  145. id: 'defaultIcon',
  146. image: favoritesImg,
  147. });
  148. //选中相机的id
  149. const selectCameraId = ref();
  150. //取消
  151. const handleMouseOver = (e) => {
  152. document.oncontextmenu = () => {
  153. return false;
  154. };
  155. const group = e.target.parent;
  156. selectCameraId.value = group.id();
  157. let defaultNode;
  158. const stage = transformer.value.getNode().getStage();
  159. const groups = stage.find('.group');
  160. const selectedCameraAttrs = groups.find((item) => item.attrs.id === selectCameraId.value).attrs;
  161. cameraInfo.value = {
  162. x: selectedCameraAttrs.x,
  163. y: selectedCameraAttrs.y,
  164. sizeX:
  165. selectCameraId.value === defaultCameraId.value
  166. ? cameraIconSize.width + defaultIconConfig.value.width
  167. : cameraIconSize.width,
  168. sizeY: cameraIconSize.height,
  169. scaleX: selectedCameraAttrs.scaleX,
  170. scaleY: selectedCameraAttrs.scaleY,
  171. rotation: selectedCameraAttrs.rotation,
  172. };
  173. if (selectCameraId.value === defaultCameraId.value) {
  174. isDefaultCamera.value = true;
  175. defaultNode = stage.findOne('#defaultIcon');
  176. } else {
  177. defaultNode = stage.findOne(`#${selectCameraId.value}`);
  178. }
  179. const tipPosition = defaultNode.absolutePosition();
  180. posTipX.value = Number(tipPosition?.x.toFixed(2)) || 0;
  181. posTipY.value = Number(tipPosition?.y.toFixed(2)) || 0;
  182. const angle = group.rotation() >= 0 ? group.rotation() : group.rotation() + 360;
  183. if (angle >= 30) {
  184. if (angle <= 150) {
  185. pos.value = TipPositionEnum.RIGHT;
  186. posTipX.value += 26;
  187. posTipY.value -= 17;
  188. } else if (angle <= 210) {
  189. pos.value = TipPositionEnum.BOTTOM;
  190. posTipY.value += 26;
  191. posTipX.value -= 50;
  192. } else {
  193. pos.value = TipPositionEnum.LEFT;
  194. posTipX.value -= 121;
  195. posTipY.value -= 25;
  196. }
  197. } else {
  198. posTipY.value -= 80;
  199. posTipX.value -= 43;
  200. }
  201. };
  202. // 还原浏览器默认鼠标事件
  203. const handleMouseLeave = () => {
  204. document.oncontextmenu = () => {
  205. return true;
  206. };
  207. isDefaultCamera.value = false;
  208. selectCameraId.value = null;
  209. };
  210. const handleDragStart = () => {
  211. isDefaultCamera.value = false;
  212. emit('change', true);
  213. };
  214. const handleStageClick = (e: any) => {
  215. defaultShow.value = false;
  216. disabledSet.value = false;
  217. document.oncontextmenu = () => {
  218. return false;
  219. };
  220. const parent = e.target.parent;
  221. //判断是否点击相机组
  222. if (parent.hasName('group')) {
  223. lastClickedGroupId.value = parent.id();
  224. emit('change', true);
  225. // 判断是否为右键点击
  226. if (e.evt.button === 2) {
  227. lastClickedGroupId.value = parent.id();
  228. const clickedVideoUrl = props.filterData.find((item) => item.code === lastClickedGroupId.value);
  229. const videoUrl = clickedVideoUrl?.pushStreamDTO?.videoUrls?.pushstreamIp;
  230. if (videoUrl) {
  231. lastClickedVideoUrl.value = videoUrl;
  232. } else {
  233. ElMessage.error('视频地址不存在');
  234. return;
  235. }
  236. isShow.value = false;
  237. posX.value = e.evt.offsetX + 20;
  238. posY.value = e.evt.offsetY;
  239. disabledSet.value = defaultCameraId.value === parent.id();
  240. defaultShow.value = true;
  241. }
  242. } else {
  243. lastClickedGroupId.value = null;
  244. //取消transformer选择
  245. const transformerNode = transformer.value.getNode();
  246. transformerNode.nodes([]);
  247. }
  248. };
  249. const handleCameraClick = (camera) => {
  250. isDefaultCamera.value = false;
  251. const transformerNode = transformer.value.getNode();
  252. const stage = transformerNode.getStage();
  253. const cameraNode = stage.findOne('#' + camera.id);
  254. // 将变换器附加到点击的相机
  255. transformerNode.nodes([cameraNode]);
  256. transformerNode.moveToTop();
  257. emit('change', true);
  258. };
  259. /**
  260. * @description 根据相机Icon的大小动态配置相机Icon的拖拽边界
  261. * @param configWidth 相机Icon的宽度
  262. * @param configHeight 相机Icon的高度
  263. * @author chauncey
  264. */
  265. function dymamicdragBoundFunc() {
  266. return function (pos: { x: number; y: number }) {
  267. // 如果没有发生缩放,则返回的scaleX为undefined,需要设置为默认缩放为1
  268. const scaleX = this.attrs.scaleX || 1;
  269. const scaleY = this.attrs.scaleY || 1;
  270. const rotation = ((this.attrs.rotation || 0) * Math.PI) / 180; // 转换为弧度
  271. const width = cameraIconSize.width;
  272. const height = cameraIconSize.height;
  273. // 计算旋转和缩放后的四个角点相对于左上角的偏移
  274. const points = [
  275. { x: 0, y: 0 }, // 左上
  276. { x: width * scaleX, y: 0 }, // 右上
  277. { x: width * scaleX, y: height * scaleY }, // 右下
  278. { x: 0, y: height * scaleY }, // 左下
  279. ].map((p) => {
  280. // 先应用旋转变换
  281. const rotatedX = p.x * Math.cos(rotation) - p.y * Math.sin(rotation);
  282. const rotatedY = p.x * Math.sin(rotation) + p.y * Math.cos(rotation);
  283. return {
  284. x: rotatedX,
  285. y: rotatedY,
  286. };
  287. });
  288. // 计算变换后的边界框
  289. const minX = Math.min(...points.map((p) => p.x));
  290. const maxX = Math.max(...points.map((p) => p.x));
  291. const minY = Math.min(...points.map((p) => p.y));
  292. const maxY = Math.max(...points.map((p) => p.y));
  293. // 计算边界框的尺寸
  294. const boxWidth = maxX - minX;
  295. const boxHeight = maxY - minY;
  296. // 计算相对于变换原点的偏移
  297. const offsetX = -minX;
  298. const offsetY = -minY;
  299. // 限制位置,确保完全在画布内
  300. const restrictedX = Math.max(0 + offsetX, Math.min(bgConfig.value.width - boxWidth + offsetX, pos.x));
  301. const restrictedY = Math.max(0 + offsetY, Math.min(bgConfig.value.height - boxHeight + offsetY, pos.y));
  302. return {
  303. x: restrictedX,
  304. y: restrictedY,
  305. };
  306. };
  307. }
  308. /**
  309. * @description 根据index动态配置相机出现位置
  310. * @param index
  311. * @author chauncey
  312. */
  313. function dymamicCameraConfig(index: number) {
  314. const maxColumn = Math.floor((bgConfig.value.height - 16) / cameraIconSize.height);
  315. const column = Math.floor(index / maxColumn);
  316. const row = index % maxColumn;
  317. const x = column * cameraIconSize.width;
  318. const y = row * cameraIconSize.height + 16;
  319. const dragBoundFunc = dymamicdragBoundFunc();
  320. return {
  321. x,
  322. y,
  323. draggable: true,
  324. name: 'group',
  325. dragBoundFunc,
  326. };
  327. }
  328. //添加相机
  329. const addCamera = (id: string, index: number) => {
  330. const existingCamera = cameras.value.find((camera) => camera.id === id);
  331. if (existingCamera) return;
  332. const config = {
  333. width: cameraIconSize.width,
  334. height: cameraIconSize.height,
  335. image: camImg,
  336. name: 'image',
  337. id: id,
  338. };
  339. const groupConfig = dymamicCameraConfig(index);
  340. const cameraDetail = {
  341. id,
  342. groupConfig,
  343. config,
  344. isDefault: false,
  345. };
  346. cameras.value.push(cameraDetail);
  347. //当只有一个相机时,自动设置默认相机
  348. if (cameras.value.length === 1) {
  349. cameras.value[0].isDefault = true;
  350. defaultCameraId.value = id;
  351. }
  352. emit('change', true);
  353. };
  354. //设置默认相机
  355. const setDefaultCamera = () => {
  356. //选中的相机id号
  357. defaultCameraId.value = lastClickedGroupId.value!;
  358. cameras.value.forEach((item) => {
  359. if (item.id === defaultCameraId.value) {
  360. item.isDefault = true;
  361. } else {
  362. item.isDefault = false;
  363. }
  364. });
  365. const transformerNode = transformer.value.getNode();
  366. const stage = transformerNode.getStage();
  367. transformerNode.nodes([]);
  368. const cameraNode = stage.findOne('#' + defaultCameraId.value);
  369. // 将变换器附加到点击的相机
  370. transformerNode.nodes([cameraNode]);
  371. defaultShow.value = false;
  372. emit('change', true);
  373. };
  374. const isShow = ref<boolean>(false);
  375. const previewCamera = () => {
  376. videoUrl.value = lastClickedVideoUrl.value!;
  377. if (!videoUrl.value) {
  378. ElMessage.warning('视频流不存在!');
  379. return;
  380. }
  381. isShow.value = true;
  382. defaultShow.value = false;
  383. };
  384. const closePreview = () => {
  385. isShow.value = false;
  386. defaultShow.value = false;
  387. };
  388. watch(
  389. lastClickedGroupId,
  390. () => {
  391. emit('changeDefaultCamera', lastClickedGroupId.value);
  392. },
  393. { immediate: true },
  394. );
  395. watch(
  396. () => cameras.value,
  397. () => {
  398. emit('sendCameraId', cameras.value);
  399. },
  400. { immediate: true, deep: true },
  401. );
  402. watch(
  403. () => props.filterData,
  404. () => {
  405. addIntegrationState(cameras.value, props.filterData);
  406. },
  407. { immediate: true, deep: true },
  408. );
  409. const clearBg = () => {
  410. bgImg.src = null as any as string;
  411. emit('change', true);
  412. layer.value.getNode().draw();
  413. };
  414. //添加背景
  415. const addBg = (imgBg) => {
  416. return new Promise((resolve) => {
  417. bgImgUrl.value = imgBg;
  418. bgImg.src = imgBg;
  419. bgImg.onload = () => {
  420. bgConfig.value.width = bgImg.width;
  421. bgConfig.value.height = bgImg.height;
  422. resizeContainer(bgImg.width, bgImg.height);
  423. resolve(null);
  424. };
  425. });
  426. };
  427. //根据id找到对应的相机
  428. const findCamera = (cameraId: string) => {
  429. const camera = cameras.value.find((item) => item.id === cameraId);
  430. return camera;
  431. };
  432. //保存布局
  433. const saveLayout = () => {
  434. const stage = transformer.value.getNode().getStage();
  435. const groups = stage.find('.group');
  436. const tempList = cloneDeep(cameras.value);
  437. const camerasLists = tempList.map((item, index) => {
  438. item.groupConfig.x = groups[index].attrs.x;
  439. item.groupConfig.y = groups[index].attrs.y;
  440. item.groupConfig.rotation = groups[index].attrs.rotation || 0;
  441. item.groupConfig.scaleX = groups[index].attrs.scaleX || 1;
  442. item.groupConfig.scaleY = groups[index].attrs.scaleY || 1;
  443. return item;
  444. });
  445. const layout = {
  446. stageConfig: stageConfig.value,
  447. bgConfig: bgConfig.value,
  448. bgImgUrl: bgImgUrl.value,
  449. defaultCameraId: defaultCameraId.value,
  450. cameraList: camerasLists,
  451. };
  452. emit('change', false);
  453. return JSON.stringify(layout);
  454. };
  455. const deleteCameraFn = () => {
  456. const index = cameras.value.findIndex((item) => item.id === lastClickedGroupId.value);
  457. index >= 0 && cameras.value.splice(index, 1);
  458. lastClickedGroupId.value = '';
  459. //取消transformer
  460. const transformerNode = transformer.value.getNode();
  461. transformerNode.nodes([]);
  462. if (!defaultShow.value) return;
  463. defaultShow.value = false;
  464. };
  465. const handleDeleteCamera = () => {
  466. if (lastClickedGroupId.value === defaultCameraId.value) {
  467. openMessageBox('警告', '此相机为默认相机,您确认要删除此相机?', deleteCameraFn);
  468. } else {
  469. deleteCameraFn();
  470. ElMessage.success('删除成功!');
  471. }
  472. emit('change', true);
  473. };
  474. //重置
  475. const resetMap = () => {
  476. bgImgUrl.value = null;
  477. clearBg();
  478. cameras.value = [];
  479. lastClickedGroupId.value = null;
  480. lastClickedGroupId.value = null;
  481. videoUrl.value = '';
  482. isShow.value = false;
  483. defaultCameraId.value = '';
  484. };
  485. /** 导入布局json */
  486. const createMap = (layout, selectId) => {
  487. addBg(layout.bgImgUrl).then((_res) => {
  488. const unExitList = [] as any[];
  489. stageConfig.value = layout.stageConfig;
  490. defaultCameraId.value = layout.defaultCameraId;
  491. layout.cameraList = layout.cameraList
  492. ?.map((item) => {
  493. item.config.image = camImg;
  494. const width = item.config.width;
  495. const height = item.config.height;
  496. const ratio = width / height;
  497. /** 老版本的相机icon比例在1.4左右,新icon已经更新,比例是1:1,为了避免新icon渲染出来变形,需要调整icon大小
  498. * 如果图标的比例在该范围,那么会将它设置为新图标的icon大小 */
  499. if (1.3 < ratio && ratio < 1.5) {
  500. item.config.width = cameraIconSize.width;
  501. item.config.height = cameraIconSize.height;
  502. }
  503. return item;
  504. })
  505. ?.filter((cam) => {
  506. if (props.cameraList.findIndex((x) => x.code === cam.id) >= 0) {
  507. return true;
  508. } else {
  509. unExitList.push(cam.code);
  510. return false;
  511. }
  512. });
  513. if (unExitList.length > 0) {
  514. ElMessage.warning('部分相机不存在,已为您删除!');
  515. const layoutNew = cloneDeep(layout);
  516. updateMinMapViewLayoutApi({
  517. layout: JSON.stringify({ ...layoutNew, isUploadBg: true }),
  518. targetId: String(selectId),
  519. viewType: 2,
  520. });
  521. }
  522. cameras.value = layout.cameraList.map((camera) => {
  523. return {
  524. ...camera,
  525. groupConfig: {
  526. ...camera.groupConfig,
  527. dragBoundFunc: dymamicdragBoundFunc(),
  528. },
  529. };
  530. });
  531. });
  532. };
  533. defineExpose({
  534. addBg,
  535. createMap,
  536. handleCameraClick,
  537. findCamera,
  538. resetMap,
  539. addCamera,
  540. resizeContainer,
  541. saveLayout,
  542. });
  543. onMounted(() => {
  544. camImg.src = cameraImgSrc;
  545. });
  546. </script>
  547. <style scoped lang="scss">
  548. .konva-map {
  549. background: #0009;
  550. scale: v-bind(transformerScale);
  551. }
  552. .map-container {
  553. width: 100%;
  554. height: 100%;
  555. overflow: hidden;
  556. }
  557. .opt-container {
  558. width: 160px;
  559. padding: 10px;
  560. border-radius: 5px;
  561. background-color: #ffffff;
  562. box-shadow: 5px 5px 5px #a3a5a5;
  563. }
  564. .opt-item {
  565. height: 30px;
  566. font-size: 14px;
  567. color: #404040;
  568. display: flex;
  569. justify-content: flex-start;
  570. align-items: center;
  571. padding-left: 8px;
  572. border-radius: 3px;
  573. cursor: pointer;
  574. &:hover {
  575. background-color: #f1f2f5;
  576. }
  577. }
  578. .disabled {
  579. background-color: #f1f2f5;
  580. color: #bcbdc0;
  581. cursor: not-allowed;
  582. }
  583. </style>