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')}} :</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 = true
sipLoading.value = true
nextTick(() => {
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 = true
return
}
monitorAlarmShow.value = true
nextTick(() => {
monitorAlarmRef.value.setRssiId(useStore().selectTopoNode.serialNo)
})
}
const openDeviceParam = () => {
if (useStore().selectTopoNode.rptState != 1 && useStore().selectTopoNode.rptState != 2) {
showVisible.value = true
return
}
deviceParamShow.value = true
nextTick(() => {
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 = null
const connectWebSocket = (url) => {
if (webSocket) webSocket.close()
webSocket = new WebSocket(url)
webSocket.onopen = () => console.log('ElectronicMap.vue WebSocket已连接')
webSocket.onmessage = handleWebSocketMessage
webSocket.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.repeaterId
const receiveRptState = message.rptState
deviceManageList.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 </div>
<div class="val">
<a-range-picker
style="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 </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="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">
<div
class="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.origin
this.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>,
<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.lat
lngBox.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} = info
if (!lat || !lng) return
const 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 = null
totalDistance = 0
pointMarkerArr = []
lastMovePointObj = null
lastMoveLineObj = null
drawPermission = false
openDrawDistance = () => {
this.map.addEventListener('click', (e) => {
this.drawPermission = true
const { lat, lng } = e.latlng
if (!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) / 1000
this.totalDistance += distance
marker.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) return
const { lat, lng } = e.latlng
if (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 = polyline
this.lastMovePointObj = marker
}
})
this.map.addEventListener('contextmenu', () => {
this.drawPermission = false
this.lastMoveLineObj && this.lastMoveLineObj.remove()
this.lastMovePointObj && this.lastMovePointObj.remove()
this.lastMoveLineObj = null
this.lastMovePointObj = null
})
}
closeDrawDistance = () => {
this.map.removeEventListener('click')
this.map.removeEventListener('mousemove')
this.lastClickPoint = null
this.pointMarkerArr.forEach(item => item.remove())
this.pointMarkerArr = []
this.lastMoveLineObj && this.lastMoveLineObj.remove()
this.lastMovePointObj && this.lastMovePointObj.remove()
this.lastMoveLineObj = null
this.lastMovePointObj = null
this.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} = value
L.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,您可以按照以下步骤进行:
选择地图服务提供商
您可以选择不同的地图服务提供商,Geofabrik 是一个提供基于 OpenStreetMap 数据的切片服务的选项。其他常见的提供商包括:
- OpenStreetMap: https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png
- Mapbox: 需要注册并获取 API 密钥。
- Carto: 需要注册并获取 API 密钥。
-
确定区域和数据集
如果您希望使用 Geofabrik 的服务,您需要确定您想要的地图区域。Geofabrik 提供了不同区域的切片,您可以在其网站上找到这些区域。通常,URL 中的标识符(例如 15173cf79060ee4a66573954f6017ab0)对应于特定的区域。 -
获取区域的切片 URL,访问 Geofabrik 网站
- 访问 Geofabrik 的切片服务页面:
- 前往 Geofabrik 网站。
- 选择区域:
- 在网站上,您可以选择特定的区域(如国家或城市)以获取相应的切片服务。
- 查找切片服务的 URL:
- 在选择的区域页面上,您通常可以找到用于该区域的切片服务 URL,类似于 https://a.tile.geofabrik.de/{区域标识}/{z}/{x}/{y}.png。
其他注意事项
- 使用条款: 在使用任何地图切片服务之前,请确保遵循其使用条款和条件,尤其是在商业应用中。
- 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">
<div
class="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)