当前位置: 首页 > news >正文

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 = 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&nbsp;</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&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="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>,&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.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,您可以按照以下步骤进行:

  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">
    <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)

相关文章:

  • Daz3D角色UE5材质优化
  • 解锁塔能科技,开启工厂绿色转型与可持续发展双引擎
  • 基于 OpenHarmony 5.0 的星闪轻量型设备应用开发-Ch1 开发环境搭建
  • 0201概述-机器学习-人工智能
  • go-zero自动生成repository文件和测试用例
  • 无人机击落技术难点与要点分析!
  • 探索 OpenHarmony 开源硬件的学习路径:从入门到实战的全攻略
  • 14. git clone
  • MySQL 架构设计:数据库的“城市规划指南“
  • ubuntu18.04安装miniforge3
  • 基于Python的网络爬虫技术研究
  • OpenBayes 一周速览|1分钟生成完整音乐,DiffRhythm人声伴奏一键搞定; Stable Virtual Camera重塑3D视频创作
  • 按键消抖(用状态机实现)
  • Elasticsearch 学习规划
  • 技术优化实战解析:Stream重构与STAR法则应用指南
  • 16. git push
  • [ctfshow web入门] web33
  • Manifold-IJ 2022.1.21 版本解析:IntelliJ IDEA 的 Java 增强插件指南
  • QEMU源码全解析 —— 块设备虚拟化(17)
  • Redis - 字典(Hash)结构和 rehash 机制
  • 复古传奇手游排行榜第一名/百度搜索名字排名优化
  • bootstrap响应式网站/知名品牌营销案例100例
  • 大良营销网站建设流程/百度平台商家
  • 周浦高端网站建设公司/长春seo顾问
  • net程序员网站开发工程师/列举五种网络营销模式
  • 网站认证费用/设计培训学院