vue3 開發電子地圖功能

在這里插入圖片描述

文章目錄

  • 一、項目背景
  • 二、頁面效果
  • 三、代碼
    • 1.ElectronicMap.vue
    • 2.TransferDeskRSSIMap.vue
    • 3.Map.js
    • 4.src/stores/index.js Vuex存儲屬性
  • 四、注意點
  • 本人其他相關文章鏈接

一、項目背景

項目采用:vue3+java+Arco Design+SpringBoot+OpenStreetMap 數據的地圖切片服務。我們的項目會上報或者手動添加多臺中轉臺,中轉臺有屬性:經度、緯度、海拔。我們想在在線/離線地圖上展示設備信息。

二、頁面效果

電子地圖

在這里插入圖片描述

中轉臺RSSI地圖

在這里插入圖片描述

點擊工具,測距

在這里插入圖片描述

點擊工具,開啟中轉臺覆蓋范圍

在這里插入圖片描述

三、代碼

1.ElectronicMap.vue

<template><layout_2 style="position: relative"><div id="electronic_map"></div><div class="transparent-box"></div><div class="transparent-box-bottom"></div><div class="--search-line in-map-tl"><div><div class="key">{{$t('TreeViewRepeater')}}&nbsp;:</div><div class="val"><a-tree-select class="arco-tree-select --arco-select" style="width: 230px":field-names="{ key: 'serialNo', title: 'name', children: 'children' }" :data="treeSelectNodeData":multiple="true" :tree-checkable="true" tree-checked-strategy="child" :max-tag-count="1"v-model:model-value="param.repeaterSNs"></a-tree-select></div></div><a-button class="huge" @click="queryTopoView" type="primary"><template #icon><icon-search size="18" /></template>{{$t('Query')}}</a-button><a-checkbox :default-checked="isChecked" @change="changeOnlineMap">{{$t('OnLineMap')}}</a-checkbox></div><statistic-repeater ref="statisticRepeaterRef" @click-transfer-desk="queryAllDeviceListByTypeFunction"></statistic-repeater></layout_2><base-info v-model:visible="baseInfoShow" v-if="baseInfoShow" ref="baseInfoRef" @refresh-flag="successFresh"></base-info><site-alias v-model:visible="siteAliasShow" v-if="siteAliasShow"></site-alias><monitor-alarm v-model:visible="monitorAlarmShow" v-if="monitorAlarmShow" ref="monitorAlarmRef"></monitor-alarm><device-param v-model:visible="deviceParamShow" v-if="deviceParamShow" ref="deviceParamRef"></device-param><a-popover arrow-class="--arrow-none" v-model:popup-visible="popoverVisible" :style="overlayStyle" content-class="--arco-popover-popup-content"@mouseenter="handlePopoverMouseEnter" @mouseleave="handlePopoverMouseLeave"><template #content><div class="dropdownBasic"><div class="dropdownItemitem"><div class="baselayersFWrapper"><svg-loader class="baselayersFIcon" name="base-info"></svg-loader></div><div class="text" @click="openBaseInfo">{{$t('BaseInfo')}}</div></div><div class="dropdownItemitem"><div class="baselayersFWrapper"><svg-loader class="baselayersFIcon" name="alarm-monitor"></svg-loader></div><div class="text" @click="openMonitorAlarm">{{$t('MonitoringAlarm')}}</div></div><div class="dropdownItemitem"><div class="baselayersFWrapper"><svg-loader class="baselayersFIcon" name="device-param"></svg-loader></div><div class="text" @click="openDeviceParam">{{$t('ParameterSetting')}}</div></div></div></template></a-popover><a-modal v-model:visible="showVisible" @ok="handleOk" :hide-cancel="true"><template #title>{{$t('Prompt')}}</template><div>{{$t('RepeaterOffline')}}</div></a-modal>
</template><script setup>
import Layout_2 from "@/views/pages/_common/layout_2.vue";
import {computed, inject, nextTick, onMounted, onUnmounted, provide, reactive, ref} from "vue";
import {LeafletMap} from "@/views/pages/_class/Map";
import {qryTransferNodeList} from "@/views/pages/topology/_request";
import {queryButtonValue} from "@/views/pages/_common/enum";
import StatisticRepeater from "@/views/pages/topology/StatisticRepeater.vue";
import SiteAlias from "@/views/pages/topology/topologyView/SiteAlias.vue";
import BaseInfo from "@/views/pages/topology/topologyView/BaseInfo.vue";
import MonitorAlarm from "@/views/pages/topology/topologyView/MonitorAlarm.vue";
import DeviceParam from "./topologyView/DeviceParam.vue";
import {useStore} from "@/stores";
import {queryAllDeviceList, queryAllDeviceListByType} from "@/views/pages/system/system.js";const initTreeLayout = ref(1)
provide('initTreeLayout', initTreeLayout.value)
const isChecked = ref(true);
const mapClass = ref(new LeafletMap())
const treeSelectNodeData = ref([])
const t = inject('t')
const deviceManageList = ref([])
const statisticRepeaterRef = ref(null)
const param = reactive({repeaterSNs: [],
})const computedPopoverVisible = computed(() => {return useStore().popoverVisible;
})
const popoverVisible = computedPopoverVisible
const computedPopoverPosition = computed(() => {return useStore().popoverPosition;
})
const popoverPosition = computedPopoverPosition
const computedSelectTopoNode = computed(() => {return useStore().selectTopoNode;
})
const selectTopoNode = computedSelectTopoNode
const baseInfoShow = ref(false)
const siteAliasShow = ref(false)
const monitorAlarmShow = ref(false)
const deviceParamShow = ref(false)
const showVisible = ref(false)
const sipLoading = ref(true)const baseInfoRef = ref(null)
const deviceParamRef = ref(null)
const monitorAlarmRef = ref(null)const queryAllDeviceListByTypeFunction = (index) => {queryAllDeviceListByType({"rptState": index}).then(response => {if (response.data) {deviceManageList.value = response.data;mapClass.value.handleAllMarkerInMap(deviceManageList.value)}})
}const queryTopoView = async () => {await getAllDeviceManageListFunction();mapClass.value.handleAllMarkerInMap(deviceManageList.value)
}const openBaseInfo = () => {baseInfoShow.value = truesipLoading.value = truenextTick(() => {const repeater = useStore().websocketRepeaterList.find(repeater => repeater.serialNo === selectTopoNode.value.serialNo);const rptState = repeater ? repeater.rptState : null;baseInfoRef.value.setData(useStore().selectTopoNode.serialNo, rptState)})
}const openMonitorAlarm = () => {if (useStore().selectTopoNode.rptState != 1 && useStore().selectTopoNode.rptState != 2) {showVisible.value = truereturn}monitorAlarmShow.value = truenextTick(() => {monitorAlarmRef.value.setRssiId(useStore().selectTopoNode.serialNo)})
}const openDeviceParam = () => {if (useStore().selectTopoNode.rptState != 1 && useStore().selectTopoNode.rptState != 2) {showVisible.value = truereturn}deviceParamShow.value = truenextTick(() => {deviceParamRef.value.baseSettingXptFunction(useStore().selectTopoNode.serialNo)})}const handlePopoverMouseEnter = () => {useStore().popoverVisible = true;
};const handlePopoverMouseLeave = () => {useStore().popoverVisible = false;
};const overlayStyle = computed(() => ({position: 'absolute',top: `${popoverPosition.value.top}px`,left: `${popoverPosition.value.left}px`,zIndex: 1000,
}));const successFresh = () => {baseInfoShow.value = false
}const handleOk = () => {showVisible.value = false
}const getTransferNodeList = () => {const principal = sessionStorage.getItem('principal');if (principal) {const principalObject = JSON.parse(principal);qryTransferNodeList({"userName": principalObject.userName}).then(response => {if (response.data) {treeSelectNodeData.value = [{serialNo: '-1',name: t(queryButtonValue[22]),children: response.data}]}})}
}
const changeOnlineMap = (val) => {mapClass.value.changeOnlineMap(val)
}const getAllDeviceManageListFunction = async () => {await queryAllDeviceList({"serialNoArr": param.repeaterSNs}).then(response => {if (response.data) {deviceManageList.value = response.data;}})
}let webSocket = nullconst connectWebSocket = (url) => {if (webSocket) webSocket.close()webSocket = new WebSocket(url)webSocket.onopen = () => console.log('ElectronicMap.vue WebSocket已連接')webSocket.onmessage = handleWebSocketMessagewebSocket.onclose = () => console.log('ElectronicMap.vue WebSocket已關閉')webSocket.onerror = (error) => console.error('ElectronicMap.vue WebSocket錯誤:', error)
}const handleWebSocketMessage = (event) => {try {const message = JSON.parse(event.data)const index = useStore().websocketRepeaterList.findIndex(item => item.serialNo === message.serialNo);if (index !== -1) {useStore().websocketRepeaterList[index] = message;} else {useStore().websocketRepeaterList.push(message);}const receiveRepeaterId = message.repeaterIdconst receiveRptState = message.rptStatedeviceManageList.value.forEach(repeater => {if (repeater.repeaterId == receiveRepeaterId) repeater.rptState = receiveRptState;})mapClass.value.handleAllMarkerInMap(deviceManageList.value)statisticRepeaterRef.value.queryTypeCountFunction();} catch (error) {console.error('ElectronicMap.vue WebSocket消息處理錯誤:', error)}
}onMounted(async () => {mapClass.value.initMap('electronic_map', deviceManageList.value)await getAllDeviceManageListFunction()mapClass.value.handleAllMarkerInMap(deviceManageList.value)changeOnlineMap(true);connectWebSocket('/ws/topoView');
})onUnmounted(() => {if (webSocket) webSocket.close()
})const init = () => {getTransferNodeList();
}
init()
</script><style scoped lang="less">
#electronic_map {height: 100%;
}
.transparent-box {position: absolute;z-index: 400;pointer-events: none;background-image: linear-gradient(to bottom, #FFFFFF, transparent);opacity: 0.8;top: 0;height: 80px;width: 100%;
}
.transparent-box-bottom {position: absolute;z-index: 400;pointer-events: none;background-image: linear-gradient(to top, #FFFFFF, transparent);opacity: 0.8;bottom: 0;height: 80px;width: 100%;
}
.in-map-tl {position: absolute;z-index: 401;left: 20px;top: 17px;
}
.in-map-rt {position: absolute;display: flex;flex-direction: column;gap: 20px;z-index: 401;top: 16px;right: 20px;.card-item {box-sizing: border-box;padding: 16px;border-radius: 12px;background: #F7F9FC;box-shadow: 0px 3px 6px 0px rgba(193, 203, 214, 0.70);width: 172px;cursor: pointer;&.active {border-radius: 12px;border: 2px solid #7FAFFF;padding: 14px;background: #FAFCFF;box-shadow: 0px 3px 6px 0px rgba(193, 203, 214, 0.70);}&-title {color: #202B40;font-family: "PingFang SC";font-size: 14px;font-style: normal;font-weight: 400;line-height: 22px;}&-count {position: relative;margin-top: 4px;height: 48px;color: #202B40;font-family: Roboto;font-size: 32px;font-style: normal;font-weight: 600;line-height: 48px;&-percentage {position: absolute;top: 19px;right: 0;color: #A7B1C6;text-align: right;font-family: "PingFang SC";font-size: 14px;font-style: normal;font-weight: 400;line-height: 22px;}}.percentage-chart {}}
}
</style>
<style>
.custom-popupp .leaflet-popup-content-wrapper {background: #EFF8FF;width: 330px
}.custom-popupp .leaflet-popup-tip-container {display: none;
}.--arrow-none {display: none
}.--arco-popover-popup-content {box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.1), 0px 8px 24px rgba(0, 0, 0, 0.1);border-radius: 12px;background: linear-gradient(180deg, #eff8ff, #fff);border: 1.5px solid #fff;box-sizing: border-box;width: 170px;
}.baselayersFIcon {width: 16px;position: relative;height: 16px;overflow: hidden;flex-shrink: 0;
}.baselayersFWrapper {width: 24px;border-radius: 80px;background-color: #fff;border: 1px solid #dfdfdf;box-sizing: border-box;height: 24px;display: flex;flex-direction: row;align-items: center;justify-content: center;padding: 8px;
}.text {flex: 1;position: relative;line-height: 22px;display: inline-block;height: 22px;cursor: pointer;
}.dropdownItemitem {align-self: stretch;height: 36px;display: flex;flex-direction: row;align-items: center;justify-content: flex-start;padding: 5px 12px 5px 16px;box-sizing: border-box;gap: 8px;
}.dropdownItemitem:hover {background: #E8F7FF;font-weight: bold;color: #3348FF;
}.dropdownBasic {width: 100%;position: relative;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 8px 0px;text-align: left;font-size: 14px;color: #202b40;font-family: 'PingFang SC';
}
</style>

2.TransferDeskRSSIMap.vue

<template><layout_2 style="position: relative"><div id="transfer_rssi_map"></div><div class="legend"><div class="legend-item"><div class="legend-item-color" style="background-color: #00AB07"></div><div class="legend-item-text">Good</div></div><div class="legend-item"><div class="legend-item-color" style="background-color: #3374FF"></div><div class="legend-item-text">Normal</div></div><div class="legend-item"><div class="legend-item-color" style="background-color: #DC6300"></div><div class="legend-item-text">Average</div></div><div class="legend-item"><div class="legend-item-color" style="background-color: #913DFF"></div><div class="legend-item-text">Bad</div></div><div class="legend-item"><div class="legend-item-color" style="background-color: #F53F3F"></div><div class="legend-item-text">Very Bad</div></div></div><div class="transparent-box"></div><div class="--search-line search-in-map-tl"><div><div class="key">發送方ID&nbsp;</div><div class="val"><a-range-pickerstyle="width: 280px":allow-clear="false"v-model="param.timeRange":disabled-date="disabledDate"><template #suffix-icon><svg-loader :width="20" :height="20" name="clock"></svg-loader></template><template #separator><svg-loader:width="16":height="16"name="arrow-right"></svg-loader></template></a-range-picker></div></div><div><div class="key">目的ID&nbsp;</div><div class="val"><a-tree-selectclass="arco-tree-select --arco-select"style="width: 230px":field-names="{key: 'serialNo',title: 'name',children: 'children',}":data="treeSelectNodeData":multiple="true":tree-checkable="true"tree-checked-strategy="child":max-tag-count="1"v-model:model-value="param.repeaterSNs"></a-tree-select></div></div><a-button class="huge" @click="search" type="primary"><template #icon><icon-search size="18" /> </template>{{ $t(queryButtonValue[2]) }}</a-button><a-checkbox :default-checked="isChecked" @change="changeOnlineMap">{{$t(queryButtonValue[5])}}</a-checkbox></div><div class="search-in-map-br"><a-tooltip :content="居中顯示" position="left"><div class="btn-item"><img :src="centerImg" /></div></a-tooltip><a-tooltip :content="測距" position="left"><divclass="btn-item":class="{ active: drawDistanceSwitch }"@click="drawDistanceSwitchChange"><img :src="distanceImg" /></div></a-tooltip><a-tooltip :content="開啟中轉臺覆蓋范圍" position="left"><div class="btn-item":class="{ active: rangeFlag }"@click="rangeClick"><img :src="coverageImg" /></div></a-tooltip></div></layout_2>
</template><script setup>
import Layout_2 from "@/views/pages/_common/layout_2.vue";
import { LeafletMap } from "@/views/pages/_class/Map";
import { onMounted, reactive, ref, inject } from "vue";
import * as moment from "moment/moment";
import centerImg from "@/assets/img/center.png";
import distanceImg from "@/assets/img/distance.png";
import coverageImg from "@/assets/img/coverage.png";
import {qryTransferNodeList,qryTransferRSSIList,
} from "@/views/pages/topology/_request";
import { commonResponse } from "@/views/pages/_common";
import { TransferDesk } from "@/views/pages/_class/TransferDesk";
import { queryButtonValue, queryColumnValue } from "@/views/pages/_common/enum";
import {queryAllDeviceList} from "@/views/pages/system/system.js";
const t = inject("t");
const isChecked = ref(true);
const param = reactive({timeRange: [null, null],repeaterSNs: [],
});
let reqParam = {startTime: null,endTime: null,repeaterSNs: [],
};
const mapClass = ref(new LeafletMap());
const transferDeskClass = ref(new TransferDesk({ mapClass }));const disabledDate = (date) => {return date.getTime() > moment().format("x");
};const changeOnlineMap = (val) => {mapClass.value.changeOnlineMap(val);
};const drawDistanceSwitch = ref(false);
const drawDistanceSwitchChange = () => {drawDistanceSwitch.value = !drawDistanceSwitch.value;if (drawDistanceSwitch.value) {mapClass.value.openDrawDistance();} else {mapClass.value.closeDrawDistance();}
};const treeSelectNodeData = ref([]);
const getTransferNodeList = () => {const principal = sessionStorage.getItem('principal');if (principal) {const principalObject = JSON.parse(principal);qryTransferNodeList({"userName": principalObject.userName}).then(response => {    commonResponse({response,onSuccess: () => {treeSelectNodeData.value = [{serialNo: "-1",name: t(queryButtonValue[22]),children: response.data,},];},});});}
};const transferRssiMap = ref(new Map());
const getRSSIList = () => {qryTransferRSSIList({...reqParam,}).then((response) => {commonResponse({response,onSuccess: () => {handleTransferDesk(response.data);},});});
};const handleTransferDesk = (data) => {transferRssiMap.value.clear();data.repeaterList.forEach((item) => {item.longitude = item.lng;item.latitude = item.lat;transferRssiMap.value.set(item.serialNo, item);});data.queryRssiResults.forEach((item) => {let obj = transferRssiMap.value.get(item.repeaterSN);obj = {...obj,...item,};if (item.pos?.longitude && item.pos?.latitude) {obj.longitude = item.pos?.longitude;obj.latitude = item.pos?.latitude;}transferRssiMap.value.set(item.repeaterSN, obj);});for (let [key, val] of transferRssiMap.value) {handleTransferDeskInMap(val);}
};const handleTransferDeskInMap = (item) => {transferDeskClass.value.handleTransferDeskInMap(item);// if (item.)
};const search = () => {reqParam = {...reqParam,...param,};reqParam.startTime = moment(reqParam.timeRange[0]).format("YYYY-MM-DD");reqParam.endTime = moment(reqParam.timeRange[1]).format("YYYY-MM-DD");delete reqParam.timeRange;getRSSIList();
};const getAllTransferInMap = () => {queryAllDeviceList({"serialNoArr": param.repeaterSNs}).then((response) => {commonResponse({response,onSuccess: () => {mapClass.value.handleAllMarkerInMap(response.data)},});});
}const rangeFlag = ref(false)
const rangeClick = () => {if (rangeFlag.value) {mapClass.value.closeCoverageRange()} else {mapClass.value.openCoverageRange()}rangeFlag.value = !rangeFlag.value
}const init = () => {param.timeRange = [moment().add(-9, "days"), moment()];param.repeaterSNs = [];getTransferNodeList();getAllTransferInMap()
};init();onMounted(() => {mapClass.value.initMap("transfer_rssi_map");changeOnlineMap(true);
});
</script><style scoped lang="less">
#transfer_rssi_map {height: 100%;
}
.transparent-box {position: absolute;z-index: 400;pointer-events: none;background-image: linear-gradient(to bottom, #ffffff, transparent);opacity: 0.8;top: 0;height: 80px;width: 100%;
}
.legend {position: absolute;box-sizing: border-box;display: flex;flex-direction: column;gap: 8px;border-radius: 6px;padding: 12px 14px;z-index: 401;left: 20px;bottom: 14px;width: 134px;height: 166px;background-color: rgba(255, 255, 255, 0.70);stroke-width: 1.5px;stroke: #FFF;filter: drop-shadow(0px 8px 24px rgba(0, 0, 0, 0.10));backdrop-filter: blur(4px);&-item {position: relative;height: 22px;&-color {position: absolute;top: 6px;border-radius: 5px;height: 10px;width: 10px;}&-text {margin-left: 18px;line-height: 22px;color: var(--80, #202B40);font-family: "PingFang SC";font-size: 13px;font-style: normal;font-weight: 400;}}
}
.transparent-box-bottom {position: absolute;z-index: 400;pointer-events: none;background-image: linear-gradient(to top, #ffffff, transparent);opacity: 0.8;bottom: 0;height: 80px;width: 100%;
}
.search-in-map-tl {position: absolute;z-index: 401;left: 20px;top: 17px;
}
.search-in-map-br {position: absolute;display: flex;flex-direction: column;gap: 20px;z-index: 401;right: 32px;bottom: 36px;width: 42px;.btn-item {box-sizing: border-box;padding: 9px;border-radius: 21px;height: 42px;width: 42px;background-color: #404750;cursor: pointer;&:hover {background-color: #3348ff;}&.active {background-color: #3348ff !important;}}
}
</style>

3.Map.js

import * as L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import './map.less'
import deviceImg0 from '@/assets/img/device-0.png'
import {ref} from "vue";
import deviceImg1 from "@/assets/img/device-1.png";
import deviceImg2 from "@/assets/img/device-2.png";
import {useStore} from "@/stores";const baseUrl = ref("")export class LeafletMap {constructor() {baseUrl.value = window.location.originthis.markers = []; // 用于存儲地圖上的標記}mapUrl = {online: 'https://a.tile.geofabrik.de/15173cf79060ee4a66573954f6017ab0/{z}/{x}/{y}.png',offline: '/mapShow/{z}/{x}/{y}.png'}getMap = () => {return this.map}rangeLayerGroup = L.layerGroup()initMap (containerId, list) {document.querySelector(`#${containerId}`).innerHTML += `<div class="latlng-box"><div class="inline">緯度:</div><div class="inline" id="${containerId}_lat"></div>,&nbsp;&nbsp;<div class="inline">經度:</div><div class="inline" id="${containerId}_lng"></div></div>`const latBox = document.querySelector(`#${containerId}_lat`)const lngBox = document.querySelector(`#${containerId}_lng`)this.map = L.map(containerId, {center: [45.7531, 126.6343],zoom: 5,minZoom: 1,maxZoom: 16,contextmenu: true,contextmenuWidth: 160,contextmenuHeight: 640,});this.tileLayer = L.tileLayer(this.mapUrl.offline)this.tileLayer.addTo(this.map)this.map.addEventListener('mousemove', (e) => {latBox.innerHTML = e.latlng.latlngBox.innerHTML = e.latlng.lng})this.rangeLayerGroup.addTo(this.map)list?.forEach(item => {this.addMarkerWithPopup(item);})}deviceMap = new Map()handleAllMarkerInMap = (list) => {// 清除之前的設備標記this.markers.forEach(marker => marker.remove());this.markers = [];this.deviceMap.clear();list?.forEach(item => {this.deviceMap.set(item.serialNo, item);this.addMarkerWithPopup(item);});this.map.invalidateSize(); // 更新地圖視圖}addMarkerWithPopup (info) {const {lat, lng, rptState, alias} = infoif (!lat || !lng) returnconst marker = L.marker([lat, lng], {icon: L.icon({iconUrl: rptState == 1 ? deviceImg1 : rptState == 2 ? deviceImg2 : deviceImg0, // 使用 deviceImg0 作為圖標iconSize: [32, 32],  // 設置圖標的大小iconAnchor: [16, 16] // 設置圖標的錨點})}).addTo(this.map);this.markers.push(marker); // 將標記添加到數組中// 添加懸停事件處理邏輯marker.on('mouseover', () => {marker.bindTooltip(alias, ).openTooltip();});const handleNodeClick = (params) => {event.preventDefault();useStore().selectTopoNode = info;// 計算彈出框的位置const chartDom = document.querySelector('#electronic_map');const chartRect = chartDom.getBoundingClientRect();const offsetX = params.containerPoint.x;const offsetY = params.containerPoint.y;useStore().popoverPosition = {top: chartRect.top + offsetY + window.scrollY,left: chartRect.left + offsetX + window.scrollX,};useStore().popoverVisible = true;}marker.on('contextmenu', handleNodeClick);}changeOnlineMap = (onlineStatus) => {if (onlineStatus) {this.tileLayer.setUrl(this.mapUrl.online)} else {this.tileLayer.setUrl(this.mapUrl.offline)}}/** 測距功能 start */lastClickPoint = nulltotalDistance = 0pointMarkerArr = []lastMovePointObj = nulllastMoveLineObj = nulldrawPermission = falseopenDrawDistance = () => {this.map.addEventListener('click', (e) => {this.drawPermission = trueconst { lat, lng } = e.latlngif (!this.lastClickPoint) {const marker = L.marker([lat, lng], { icon: L.divIcon({ className: 'point-start' }) })marker.bindTooltip('0km', {offset: [0, -14],permanent: true,direction: 'top'}).openTooltip()marker.addTo(this.map)this.pointMarkerArr.push(marker)} else {const marker = L.marker([lat, lng], { icon: L.divIcon({ className: 'point-process' }) })const distance = this.calculateDistance(this.lastClickPoint[0], this.lastClickPoint[1], lat, lng) / 1000this.totalDistance += distancemarker.bindTooltip(`${this.totalDistance.toFixed(3)}km`, {offset: [0, -14],permanent: true,direction: 'top'}).openTooltip()marker.addTo(this.map)const polyline = L.polyline([this.lastClickPoint, [lat, lng]], { color: '#FFA100' })polyline.addTo(this.map)this.pointMarkerArr.push(marker)this.pointMarkerArr.push(polyline)}this.lastClickPoint = [lat, lng]})this.map.addEventListener('mousemove', (e) => {if (!this.drawPermission) returnconst { lat, lng } = e.latlngif (this.lastClickPoint) {const polyline = L.polyline([this.lastClickPoint, [lat, lng]], {color: '#FFA100',dashArray: '8'})const marker = L.marker([lat, lng], { icon: L.divIcon({ className: 'point-start' }) })marker.bindTooltip(`右鍵取消`, {offset: [0, -14],permanent: true,direction: 'top'}).openTooltip()this.lastMoveLineObj && this.lastMoveLineObj.remove()this.lastMovePointObj && this.lastMovePointObj.remove()polyline.addTo(this.map)marker.addTo(this.map)this.lastMoveLineObj = polylinethis.lastMovePointObj = marker}})this.map.addEventListener('contextmenu', () => {this.drawPermission = falsethis.lastMoveLineObj && this.lastMoveLineObj.remove()this.lastMovePointObj && this.lastMovePointObj.remove()this.lastMoveLineObj = nullthis.lastMovePointObj = null})}closeDrawDistance = () => {this.map.removeEventListener('click')this.map.removeEventListener('mousemove')this.lastClickPoint = nullthis.pointMarkerArr.forEach(item => item.remove())this.pointMarkerArr = []this.lastMoveLineObj && this.lastMoveLineObj.remove()this.lastMovePointObj && this.lastMovePointObj.remove()this.lastMoveLineObj = nullthis.lastMovePointObj = nullthis.totalDistance = 0}// Vincenty公式進行計算(更準確但計算復雜度較高)calculateDistance (lat1, lon1, lat2, lon2) {const a = 6378137; // 長軸半徑,單位為米const b = 6356752.314245; // 短軸半徑,單位為米const f = 1 / 298.257223563; // 扁率const L = (lon2 - lon1) * Math.PI / 180;const U1 = Math.atan((1 - f) * Math.tan(lat1 * Math.PI / 180));const U2 = Math.atan((1 - f) * Math.tan(lat2 * Math.PI / 180));const sinU1 = Math.sin(U1), cosU1 = Math.cos(U1);const sinU2 = Math.sin(U2), cosU2 = Math.cos(U2);let iterLimit = 100;let lambda = L, lambdaP, sinSigma, cosSigma, sigma, sinAlpha, cosSqAlpha, cos2SigmaM;do {const sinLambda = Math.sin(lambda), cosLambda = Math.cos(lambda);sinSigma = Math.sqrt((cosU2 * sinLambda) * (cosU2 * sinLambda) +(cosU1 * sinU2 - sinU1 * cosU2 * cosLambda) * (cosU1 * sinU2 - sinU1 * cosU2 * cosLambda));if (sinSigma === 0) {return 0; // 兩點重合}cosSigma = sinU1 * sinU2 + cosU1 * cosU2 * cosLambda;sigma = Math.atan2(sinSigma, cosSigma);sinAlpha = cosU1 * cosU2 * sinLambda / sinSigma;cosSqAlpha = 1 - sinAlpha * sinAlpha;cos2SigmaM = cosSigma - 2 * sinU1 * sinU2 / cosSqAlpha;const C = f / 16 * cosSqAlpha * (4 + f * (4 - 3 * cosSqAlpha));lambdaP = lambda;lambda = L + (1 - C) * f * sinAlpha *(sigma + C * sinSigma * (cos2SigmaM + C * cosSigma * (-1 + 2 * cos2SigmaM * cos2SigmaM)));} while (Math.abs(lambda - lambdaP) > 1e-12 && --iterLimit > 0);if (iterLimit === 0) {return NaN; // 迭代次數過多}const uSq = cosSqAlpha * (a * a - b * b) / (b * b);const A = 1 + uSq / 16384 * (4096 + uSq * (-768 + uSq * (320 - 175 * uSq)));const B = uSq / 1024 * (256 + uSq * (-128 + uSq * (74 - 47 * uSq)));const deltaSigma = B * sinSigma * (cos2SigmaM + B / 4 *(cosSigma * (-1 + 2 * cos2SigmaM * cos2SigmaM) -B / 6 * cos2SigmaM * (-3 + 4 * sinSigma * sinSigma) *(-3 + 4 * cos2SigmaM * cos2SigmaM)));const distance = b * A * (sigma - deltaSigma);return distance;}/** 測距功能 end */openCoverageRange = () => {for (const [key, value] of this.deviceMap) {const {lat, lng} = valueL.circle([lat, lng], {radius: 100000,color: 'rgba(51, 72, 255, 0.50)',fillColor: 'rgba(51, 116, 255, 0.30)',}).addTo(this.rangeLayerGroup) // 磊哥說寫個假的}}closeCoverageRange = () => {this.rangeLayerGroup.clearLayers()}
}

4.src/stores/index.js Vuex存儲屬性

/*** @Name:* @Author:賈志博* @description:*/
import {defineStore} from "pinia";
import {ref} from 'vue'export const useStore = defineStore('main', () => {const mode = ref(0)const setMode = (modeVal) => {mode.value = modeVal}const getMode = () => {return mode.value}const openMenuItem = ref([]);const setOpenMenuItemFunction = (modeVal) => {openMenuItem.value = modeVal}const getOpenMenuItemFunction = () => {return openMenuItem}const selectedMenuItemKey = ref(null);const setSelectedMenuItemKeyFunction = (modeVal) => {selectedMenuItemKey.value = modeVal}const getSelectedMenuItemKeyFunction = () => {return selectedMenuItemKey}const selectedMenuKey = ref("");const setSelectedMenuKeyFunction = (modeVal) => {selectedMenuKey.value = modeVal}const getSelectedMenuKeyFunction = () => {return selectedMenuKey}const hasAuth = ref(false)const routes = ref([])const popoverVisible = ref(false);const popoverPosition = ref({ top: 0, left: 0 });const selectTopoNode = ref(null)const websocketRepeaterList = ref([])return {getMode,setMode,setSelectedMenuKeyFunction,getSelectedMenuKeyFunction,setSelectedMenuItemKeyFunction,getSelectedMenuItemKeyFunction,setOpenMenuItemFunction,getOpenMenuItemFunction,hasAuth,routes,popoverVisible,popoverPosition,selectTopoNode,websocketRepeaterList,}
})

四、注意點

注意點1:地圖分在線地圖/離線地圖,離線地圖需要上傳瓦片地圖。

注意點2:在線地圖采用,在線地圖服務:https://a.tile.geofabrik.de/

注意點3:使用OpenStreetMap地圖步驟
要獲取類似于 https://a.tile.geofabrik.de/15173cf79060ee4a66573954f6017ab0/{z}/{x}/{y}.png 的地圖切片 URL,您可以按照以下步驟進行:

  1. 選擇地圖服務提供商
    您可以選擇不同的地圖服務提供商,Geofabrik 是一個提供基于 OpenStreetMap 數據的切片服務的選項。其他常見的提供商包括:
  • OpenStreetMap: https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png
  • Mapbox: 需要注冊并獲取 API 密鑰。
  • Carto: 需要注冊并獲取 API 密鑰。
  1. 確定區域和數據集
    如果您希望使用 Geofabrik 的服務,您需要確定您想要的地圖區域。Geofabrik 提供了不同區域的切片,您可以在其網站上找到這些區域。通常,URL 中的標識符(例如 15173cf79060ee4a66573954f6017ab0)對應于特定的區域。

  2. 獲取區域的切片 URL,訪問 Geofabrik 網站

  • 訪問 Geofabrik 的切片服務頁面:
    • 前往 Geofabrik 網站。
  • 選擇區域:
    • 在網站上,您可以選擇特定的區域(如國家或城市)以獲取相應的切片服務。
  • 查找切片服務的 URL:
    • 在選擇的區域頁面上,您通常可以找到用于該區域的切片服務 URL,類似于 https://a.tile.geofabrik.de/{區域標識}/{z}/{x}/{y}.png。
  1. 其他注意事項
  • 使用條款: 在使用任何地圖切片服務之前,請確保遵循其使用條款和條件,尤其是在商業應用中。
  • API 密鑰: 某些地圖服務(如 Mapbox 和 Carto)需要您注冊并獲取 API 密鑰才能使用其服務。

通過上述步驟,您可以獲取并使用類似于 https://a.tile.geofabrik.de/15173cf79060ee4a66573954f6017ab0/{z}/{x}/{y}.png 的地圖切片 URL。

注意點4:地圖下方會實時顯示鼠標的經緯度信息。

注意點5:地圖右下角還有3個小工具:居中顯示、測距、開啟中轉臺覆蓋范圍

  • 居中顯示:未開發
  • 測距:已開發,
  • 開啟中轉臺覆蓋范圍:這個是用戶會配置顏色范圍
    在這里插入圖片描述

操作手冊中有詳細介紹

在這里插入圖片描述

注意點6:由于代碼比較亂,這里詳細介紹下具體使用。

電子地圖頁面顯示

<div id="electronic_map"></div>import {onMounted, ref} from "vue";
import {LeafletMap} from "@/views/pages/_class/Map";const mapClass = ref(new LeafletMap())
const deviceManageList = ref([])
const isChecked = ref(true);const changeOnlineMap = (val) => {mapClass.value.changeOnlineMap(val)
}onMounted(async () => {mapClass.value.initMap('electronic_map', deviceManageList.value)mapClass.value.handleAllMarkerInMap(deviceManageList.value)changeOnlineMap(true);
})

右下角工具

<div class="search-in-map-br"><a-tooltip :content="$t(queryColumnValue[25])" position="left"><div class="btn-item"><img :src="centerImg" /></div></a-tooltip><a-tooltip :content="$t(queryColumnValue[26])" position="left"><divclass="btn-item":class="{ active: drawDistanceSwitch }"@click="drawDistanceSwitchChange"><img :src="distanceImg" /></div></a-tooltip><a-tooltip :content="$t(queryColumnValue[27])" position="left"><div class="btn-item":class="{ active: rangeFlag }"@click="rangeClick"><img :src="coverageImg" /></div></a-tooltip>
</div>import centerImg from "@/assets/img/center.png";
import distanceImg from "@/assets/img/distance.png";
import coverageImg from "@/assets/img/coverage.png";const drawDistanceSwitch = ref(false);
const rangeFlag = ref(false)const drawDistanceSwitchChange = () => {drawDistanceSwitch.value = !drawDistanceSwitch.value;if (drawDistanceSwitch.value) {mapClass.value.openDrawDistance();} else {mapClass.value.closeDrawDistance();}
};const rangeClick = () => {if (rangeFlag.value) {mapClass.value.closeCoverageRange()} else {mapClass.value.openCoverageRange()}rangeFlag.value = !rangeFlag.value
}

本人其他相關文章鏈接

1.vue3 開發電子地圖功能
2.vue java 實現大地圖切片上傳
3.java導入excel更新設備經緯度度數或者度分秒
4.快速上手Vue3國際化 (i18n)

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/900647.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/900647.shtml
英文地址,請注明出處:http://en.pswp.cn/news/900647.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

oracle 存儲體系結構

oracle 存儲體系結構 參考&#xff1a; Logical Storage Structures (oracle.com)

python-leetcode 66.尋找旋轉排序數組中的最小值

題目&#xff1a; 已知一個長度為n的數組&#xff0c;預先按照升序排列&#xff0c;經由1到n次旋轉后&#xff0c;得到輸入數組&#xff0c;例如&#xff0c;原數組 nums [0,1,2,4,5,6,7] 在變化后可能得到&#xff1a; 若旋轉 4 次&#xff0c;則可以得到 [4,5,6,7,0,1,2]若…

【MATLAB第113期】基于MATLAB的EFAST擴展傅里葉幅度敏感性分析方法(有目標函數)

【MATLAB第113期】基于MATLAB的EFAST擴展傅里葉幅度敏感性分析方法&#xff08;有目標函數&#xff09; 一、方法概述 擴展傅里葉幅度敏感性檢驗&#xff08;EFAST&#xff09;是一種基于頻域分析的全局敏感性分析方法&#xff0c;能夠同時評估模型參數的一階敏感性&#xff…

Tiktok 關鍵字 視頻及評論信息爬蟲(1) [2025.04.07]

&#x1f64b;?♀?Tiktok APP的基于關鍵字檢索的視頻及評論信息爬蟲共分為兩期&#xff0c;希望對大家有所幫助。 第一期見下文。 第二期&#xff1a;基于視頻URL的評論信息爬取 1. Node.js環境配置 首先配置 JavaScript 運行環境&#xff08;如 Node.js&#xff09;&#x…

【愚公系列】《高效使用DeepSeek》058-選題策劃

??【技術大咖愚公搬代碼:全棧專家的成長之路,你關注的寶藏博主在這里!】?? ??開發者圈持續輸出高質量干貨的"愚公精神"踐行者——全網百萬開發者都在追更的頂級技術博主! ?? 江湖人稱"愚公搬代碼",用七年如一日的精神深耕技術領域,以"…

零基礎教程:Windows電腦安裝Linux系統(雙系統/虛擬機)全攻略

一、安裝方式選擇 方案對比表 特性雙系統安裝虛擬機安裝性能原生硬件性能依賴宿主機資源分配磁盤空間需要獨立分區&#xff08;建議50GB&#xff09;動態分配&#xff08;默認20GB起&#xff09;內存占用獨占全部內存需手動分配&#xff08;建議4GB&#xff09;啟動方式開機選…

LeetCode 2968.執行操作使頻率分數最大

給你一個下標從 0 開始的整數數組 nums 和一個整數 k 。 你可以對數組執行 至多 k 次操作&#xff1a; 從數組中選擇一個下標 i &#xff0c;將 nums[i] 增加 或者 減少 1 。 最終數組的頻率分數定義為數組中眾數的 頻率 。 請你返回你可以得到的 最大 頻率分數。 眾數指的…

excel經驗

Q:我現在有一個excel&#xff0c;有一列數據&#xff0c;大概兩千多行。如何在這一列中 篩選出具有關鍵字的內容&#xff0c;并輸出到另外一列中。 A: 假設數據在A列&#xff08;A1開始&#xff09;&#xff0c;關鍵字為“ABC”在相鄰空白列&#xff08;如B1&#xff09;輸入公…

HTTP查詢參數示例(XMLHttpRequest查詢參數)(帶查詢參數的HTTP接口示例——以python flask接口為例)flask查詢接口

文章目錄 HTTP查詢參數請求示例接口文檔——獲取城市列表代碼示例效果 帶查詢參數的HTTP接口示例——以python flask接口為例app.pyREADME.md運行應用API示例客戶端示例關鍵實現說明&#xff1a;運行方法&#xff1a; HTTP查詢參數請求示例 接口文檔——獲取城市列表 代碼示例…

將飛帆制作的網頁作為 div 集成到自己的網頁中

并且自己的網頁可以和飛帆中的控件相互調用函數。效果&#xff1a; 上鏈接 將飛帆制作的網頁作為 div 集成到自己的網頁中 - 文貝 進入可以復制、運行代碼

Redis主從復制:告別單身Redis!

目錄 一、 為什么需要主從復制&#xff1f;&#x1f914;二、 如何搭建主從架構&#xff1f;前提條件?步驟&#x1f4c1; 創建工作目錄&#x1f4dc; 創建 Docker Compose 配置文件&#x1f680; 啟動所有 Redis&#x1f50d; 驗證主從狀態 &#x1f4a1; 重要提示和后續改進 …

k8s 1.30.6版本部署(使用canal插件)

#系統環境準備 參考 https://blog.csdn.net/dingzy1/article/details/147062698?spm1001.2014.3001.5501 #配置下載源 curl -fsSL https://mirrors.aliyun.com/kubernetes-new/core/stable/v1.30/deb/Release.key |gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyri…

機器學習的一百個概念(7)獨熱編碼

前言 本文隸屬于專欄《機器學習的一百個概念》&#xff0c;該專欄為筆者原創&#xff0c;引用請注明來源&#xff0c;不足和錯誤之處請在評論區幫忙指出&#xff0c;謝謝&#xff01; 本專欄目錄結構和參考文獻請見[《機器學習的一百個概念》 ima 知識庫 知識庫廣場搜索&…

RHCSA復習

在Linux中&#xff0c; wrx 分別代表寫&#xff08;write&#xff09;、讀&#xff08;read&#xff09;和執行&#xff08;execute&#xff09;權限&#xff0c;它們對應的權限值分別是&#xff1a; - r &#xff08;讀權限&#xff09;&#xff1a;權限值為4。 - w &am…

“樂企“平臺如何重構業財稅票全流程生態?

2025年&#xff0c;國家稅務總局持續推進的"便民辦稅春風行動"再次推進數字化服務升級&#xff0c;其中"樂企"平臺作為稅務信息化的重要載體&#xff0c;持續優化數電票服務能力&#xff0c;為企業提供更高效、更規范的稅務管理支持。在這一背景下&#xf…

Android audio(6)-audiopolicyservice介紹

AudioPolicyService 是策略的制定者&#xff0c;比如某種 Stream 類型不同設備的音量&#xff08;index/DB&#xff09;是多少、某種 Stream 類型的音頻數據流對應什么設備等等。而 AudioFlinger 則是策略的執行者&#xff0c;例如具體如何與音頻設備通信&#xff0c;維護現有系…

Boost庫搜索引擎項目(版本1)

Boost庫搜索引擎 項目開源地址 Github&#xff1a;https://github.com/H0308/BoostSearchingEngine Gitee&#xff1a;https://gitee.com/EPSDA/BoostSearchingEngine 版本聲明 當前為最初版本&#xff0c;后續會根據其他內容對當前項目進行修改&#xff0c;具體見后續版本…

git分支合并信息查看

TortoiseGit工具 1、選擇"Revision graph" 2、勾選view中的 Show branchings and merges Arrows point towards merges 3、圖案說明 紅色部分?&#xff1a;代表當前分支 橙色部分?&#xff1a;代表遠程分支 黃色部分?&#xff1a;代表一個tag 綠色部分?&#xf…

Java學習筆記(多線程):ReentrantLock 源碼分析

本文是自己的學習筆記&#xff0c;主要參考資料如下 JavaSE文檔 1、AQS 概述1.1、鎖的原理1.2、任務隊列1.2.1、結點的狀態變化 1.3、加鎖和解鎖的簡單流程 2、ReentrantLock2.1、加鎖源碼分析2.1.1、tryAcquire()的具體實現2.1.2、acquirQueued()的具體實現2.1.3、tryLock的具…

在C++11及后續標準中,auto和decltype是用于類型推導的關鍵特性,它們的作用和用法。

在C11及后續標準中&#xff0c;auto和decltype是用于類型推導的關鍵特性&#xff0c;它們的作用和用法有所不同。以下是詳細說明&#xff1a; 1. auto 關鍵字 基本作用 自動推導變量的類型&#xff08;根據初始化表達式&#xff09;主要用于簡化代碼&#xff0c;避免顯式書寫…