高德地图实现点聚合

43 阅读8分钟

聚合时根据数量展示不同图标。非聚合时根据状态展示不同图标。点击点位展示弹层,点击其他区域可关闭

image.png

image.png

blue.png

darkRed.png

green.png

orange.png

position.png

position_down.png

position_green.png

position_warning.png

positon_error.png

red.png

index.html(key替换成自己的)

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
  <meta name="renderer" content="webkit">
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
  <link rel="icon" href="/logo.png">
    <link rel="dns-prefetch" href="//api.tianditu.gov.cn">
    <!--  <link rel="icon" href="/favicon.ico">-->
  <title>江南水务智慧化管控平台</title>
    <script type="text/javascript">
        window._AMapSecurityConfig = {
            securityJsCode: "替换",
        };
    </script>
  <!--[if lt IE 11]><script>window.location.href='/html/ie.html';</script><![endif]-->
    <script src="https://a.amap.com/jsapi_demos/static/demo-center/js/demoutils.js"></script>

    <script src="https://a.amap.com/jsapi_demos/static/china.js"></script>
    <script src="https://webapi.amap.com/maps?v=2.0&key=替换&plugin=AMap.MarkerCluster,AMap.IndexCluster,AMap.GeoJSON,AMap.Geocoder,AMap.Scale,AMap.ToolBar,AMap.DistrictSearch"></script>
    <script src="https://webapi.amap.com/loca?v=2.0.0&key=替换"></script>
    <script src="https://webapi.amap.com/ui/1.1/main.js?v=1.1.1"></script>
  <style>
    html,
    body,
    #app {
      height: 100%;
      margin: 0px;
      padding: 0px;
    }

    .chromeframe {
      margin: 0.2em 0;
      background: #ccc;
      color: #000;
      padding: 0.2em 0;
    }

    #loader-wrapper {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      z-index: 999999;
    }

    #loader {
      display: block;
      position: relative;
      left: 50%;
      top: 50%;
      width: 150px;
      height: 150px;
      margin: -75px 0 0 -75px;
      border-radius: 50%;
      border: 3px solid transparent;
      border-top-color: #FFF;
      -webkit-animation: spin 2s linear infinite;
      -ms-animation: spin 2s linear infinite;
      -moz-animation: spin 2s linear infinite;
      -o-animation: spin 2s linear infinite;
      animation: spin 2s linear infinite;
      z-index: 1001;
    }

    #loader:before {
      content: "";
      position: absolute;
      top: 5px;
      left: 5px;
      right: 5px;
      bottom: 5px;
      border-radius: 50%;
      border: 3px solid transparent;
      border-top-color: #FFF;
      -webkit-animation: spin 3s linear infinite;
      -moz-animation: spin 3s linear infinite;
      -o-animation: spin 3s linear infinite;
      -ms-animation: spin 3s linear infinite;
      animation: spin 3s linear infinite;
    }

    #loader:after {
      content: "";
      position: absolute;
      top: 15px;
      left: 15px;
      right: 15px;
      bottom: 15px;
      border-radius: 50%;
      border: 3px solid transparent;
      border-top-color: #FFF;
      -moz-animation: spin 1.5s linear infinite;
      -o-animation: spin 1.5s linear infinite;
      -ms-animation: spin 1.5s linear infinite;
      -webkit-animation: spin 1.5s linear infinite;
      animation: spin 1.5s linear infinite;
    }


    @-webkit-keyframes spin {
      0% {
        -webkit-transform: rotate(0deg);
        -ms-transform: rotate(0deg);
        transform: rotate(0deg);
      }

      100% {
        -webkit-transform: rotate(360deg);
        -ms-transform: rotate(360deg);
        transform: rotate(360deg);
      }
    }

    @keyframes spin {
      0% {
        -webkit-transform: rotate(0deg);
        -ms-transform: rotate(0deg);
        transform: rotate(0deg);
      }

      100% {
        -webkit-transform: rotate(360deg);
        -ms-transform: rotate(360deg);
        transform: rotate(360deg);
      }
    }


    #loader-wrapper .loader-section {
      position: fixed;
      top: 0;
      width: 51%;
      height: 100%;
      background: #7171C6;
      z-index: 1000;
      -webkit-transform: translateX(0);
      -ms-transform: translateX(0);
      transform: translateX(0);
    }

    #loader-wrapper .loader-section.section-left {
      left: 0;
    }

    #loader-wrapper .loader-section.section-right {
      right: 0;
    }


    .loaded #loader-wrapper .loader-section.section-left {
      -webkit-transform: translateX(-100%);
      -ms-transform: translateX(-100%);
      transform: translateX(-100%);
      -webkit-transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
      transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
    }

    .loaded #loader-wrapper .loader-section.section-right {
      -webkit-transform: translateX(100%);
      -ms-transform: translateX(100%);
      transform: translateX(100%);
      -webkit-transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
      transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
    }

    .loaded #loader {
      opacity: 0;
      -webkit-transition: all 0.3s ease-out;
      transition: all 0.3s ease-out;
    }

    .loaded #loader-wrapper {
      visibility: hidden;
      -webkit-transform: translateY(-100%);
      -ms-transform: translateY(-100%);
      transform: translateY(-100%);
      -webkit-transition: all 0.3s 1s ease-out;
      transition: all 0.3s 1s ease-out;
    }

    .no-js #loader-wrapper {
      display: none;
    }

    .no-js h1 {
      color: #222222;
    }

    #loader-wrapper .load_title {
      font-family: 'Open Sans';
      color: #FFF;
      font-size: 19px;
      width: 100%;
      text-align: center;
      z-index: 9999999999999;
      position: absolute;
      top: 60%;
      opacity: 1;
      line-height: 30px;
    }

    #loader-wrapper .load_title span {
      font-weight: normal;
      font-style: italic;
      font-size: 13px;
      color: #FFF;
      opacity: 0.5;
    }
  </style>
</head>

<body>
  <div id="app">
    <div id="loader-wrapper">
      <div id="loader"></div>
      <div class="loader-section section-left"></div>
      <div class="loader-section section-right"></div>
      <div class="load_title">正在加载系统资源,请耐心等待</div>
    </div>
  </div>
  <script type="module" src="/src/main.js"></script>
</body>

</html>
<template>
    <div class="tiandi-map-container">
      <!--      地图区域-->
      <div v-loading="map_loading" element-loading-text="地图加载中"
           :element-loading-spinner="svg"
           element-loading-svg-view-box="-10, -10, 50, 50"
           element-loading-background="#001431" style="width:100%;height: 100%;position: relative;">
        <div
            style="width:100%;
                    height: 100%;
                    border: none;
                    box-sizing: border-box;
                    position: relative;"
            ref="map_container"
            id="map_container"
        >
        </div>
      </div>
    </div>

</template>

<script setup>
import {getInfoList} from "../../../api/screen/project";

const map_container = ref(null)

const curMap = ref();
const curAMap = ref(null) // 高德地图引用
const map_loading = ref(true)
const selectedInfo = ref({});
const infoWindow = ref(null)

const dataList = ref([])

const { proxy } = getCurrentInstance();
const { pm_project_period } = proxy.useDict("pm_project_period");

const svg = `
        <path class="path" d="
          M 30 15
          L 28 17
          M 25.61 25.61
          A 15 15, 0, 0, 1, 15 30
          A 15 15, 0, 1, 1, 27.99 7.5
          L 15 15
        " style="stroke-width: 4px; fill: rgba(0, 0, 0, 0)"/>
      `

//地图及边界
const load_map = async () => {
  map_loading.value = true
  curMap.value = null
  let query = {
    pageSize:1000,
    pageNum:1
  }
  let res = await getInfoList(query)
  dataList.value = res.rows;
  const district = new AMap.DistrictSearch({ subdistrict: 0, extensions: 'all', level: 'province' });
  // 设置江阴市范围
  district.search('江阴市', function (status, result) {
    // 查询成功时,result即为对应的行政区信息
    // 这里是整个江阴市的边界经纬度
    const bounds = result.districtList[0].boundaries
    const mask = []
    for (let i = 0; i < bounds.length; i++) {
      mask.push([bounds[i]])
    }
    curAMap.value = new AMap.Map("map_container", {  // 设置地图容器id
      mask: mask, // 为Map实例制定掩模的路径,各图层将值显示路径范围内图像,3D模式下有效
      zoom: 11.6, // 设置当前显示级别
      expandZoomRange: true, // 开启显示范围设置
      zooms: [11.6, 20], //最小显示级别为7,最大显示级别为20
      center: [120.284794, 31.841642], // 设置地图中心点位置 暂定江南水务总部
      viewMode: "3D",    // 特别注意,设置为3D则其他地区不显示
      zoomEnable: true, // 是否可以缩放地图
      resizeEnable: true,
      mapStyle: "amap://styles/darkblue",
      layers: [
        // 卫星
        // new AMap.TileLayer.Satellite(),
        // 路网
        // new AMap.TileLayer.RoadNet()
      ]
    });
    // 添加描边
    for (let i = 0; i < bounds.length; i++) {
      const polyline = new AMap.Polyline({
        path: bounds[i], // polyline 路径,支持 lineString 和 MultiLineString
        strokeColor: '#3078AC', // 线条颜色,使用16进制颜色代码赋值。默认值为#00D3FC
        strokeWeight: 2, // 轮廓线宽度,默认为:2
        // map:map // 这种方式相当于: polyline.setMap(map);
      })
      polyline.setMap(curAMap.value);
    }
    curMap.value = curAMap.value

    // ★ 1. 点击空白处关闭 InfoWindow
    curMap.value.on('click', closeInfoWindow)
    //点标记
    makeMarkers(dataList.value);
  })
}

// ★ 2. 关闭 InfoWindow(如果已打开)
function closeInfoWindow() {
  if (infoWindow.value) {
    infoWindow.value.close()
    infoWindow.value = null
  }
}

//点位
function makeMarkers(devices) {
  // 添加标记
  //橙色 准备阶段 1
  let rowsOrange = []
  //绿色 已完成 5
  let rowsGreen = []
  //灰色 已关闭 4
  let rowsGray = []
  //红色 施工阶段 2
  let rowsRed = []
  //蓝色 完工阶段 3
  let rowsBlue = []

  var points = []

  let rank = 1
  for (let i = 0; i < devices.length; i++) {
    if (devices[i].latLng) {
      let latLng = devices[i].latLng.split(',')
      let info = {
        latLng: latLng,
        flowRecordId: devices[i].flowRecordId,
        projectName: devices[i].projectName,
        projectCode: devices[i].projectCode,
        projectType: devices[i].projectType,
        flowId: devices[i].flowId,
        projectPeriod: devices[i].projectPeriod,
        projectManager: devices[i].projectManager,
        projectId: devices[i].projectId,
      }
      //所有点
      points.push({
        weight: 1,
        name: devices[i].flowRecordId,
        lnglat: latLng,
        info: info
      })
      if (devices[i].projectPeriod === '1') {
        rowsOrange.push(info)
      } else if (devices[i].projectPeriod === '2') {
        rowsRed.push(info)
      } else if (devices[i].projectPeriod === '3') {
        rowsBlue.push(info)
      } else if (devices[i].projectPeriod === '4') {
        rowsGray.push(info)
      } else if (devices[i].projectPeriod === '5') {
        rowsGreen.push(info)
      }
      rank++
    }
  }

  //marker点位做点击事件
  var positions = []
  for (let i = 0; i < rowsGreen.length; i++) {
    let latLng = rowsGreen[i].latLng
    positions.push({
      lng: latLng[0],
      lat: latLng[1],
      info: rowsGreen[i],
      type: 'green'
    })
  }
  for (let i = 0; i < rowsGray.length; i++) {
    let latLng = rowsGray[i].latLng
    positions.push({
      lng: latLng[0],
      lat: latLng[1],
      info: rowsGray[i],
      type: 'gray'
    })
  }
  for (let i = 0; i < rowsRed.length; i++) {
    let latLng = rowsRed[i].latLng
    positions.push({
      lng: latLng[0],
      lat: latLng[1],
      info: rowsRed[i],
      type: 'red'
    })
  }
  for (let i = 0; i < rowsOrange.length; i++) {
    let latLng = rowsOrange[i].latLng
    positions.push({
      lng: latLng[0],
      lat: latLng[1],
      info: rowsOrange[i],
      type: 'orange'
    })
  }
  for (let i = 0; i < rowsBlue.length; i++) {
    let latLng = rowsBlue[i].latLng
    positions.push({
      lng: latLng[0],
      lat: latLng[1],
      info: rowsBlue[i],
      type: 'blue'
    })
  }

  handlerMarkerCluster(points)
  setTimeout(() => {
    map_loading.value = false
  }, 500);
}

function deviceStatusMakeColor(status) {
  //灰色 已关闭 4
  if ([ '4', 4].includes(status)) {
    return '#909399'
    //绿色 已完成 5
  } else if (['5', 5].includes(status)) {
    return '#67C23A'
    //红色 施工阶段 2
  } else if (['2', 2].includes(status)) {
    return '#F56C6C'
    //橙色 准备阶段 1
  } else if (['1', 1].includes(status)) {
    return 'orange'
    // 蓝色 完工阶段 3
  } else if (['3', 3].includes(status)) {
    return 'blue'
  }
}
function getProjectType(value) {
  const dict = pm_project_period.value;
  for (let i = 0; i < dict.length; i++) {
    if (dict[i].value == value) {
      return dict[i].label
    }
  }
  return ''
}
function statusIcon(status){
  //灰色 已关闭 4
  if ([ '4', 4].includes(status)) {
    return marker04Img
    //绿色 已完成 5
  } else if (['5', 5].includes(status)) {
    return marker05Img
    //红色 施工阶段 2
  } else if (['2', 2].includes(status)) {
    return marker02Img
    //橙色 准备阶段 1
  } else if (['1', 1].includes(status)) {
    return marker01Img
    // 蓝色 完工阶段 3
  } else if (['3', 3].includes(status)) {
    return marker03Img
  }
}

//点聚合逻辑
const handlerMarkerCluster = (points) => {
  curAMap.value = new AMap.MarkerCluster(
      curMap.value,
      points,
      {
        gridSize: 60,
        renderClusterMarker: handlerRenderClusterMarker, // 自定义聚合点样式
        renderMarker: handlerRenderMarker // 自定义点样式
      }
  );

  // 聚合点点击事件
  curAMap.value.on('click', (e) => {
    const { clusterData, marker } = e

    // 单个点
    const innerText = marker.dom.innerText
    if (!innerText) return

    // 计算聚合点的中心点
    let [allLng, allLat] = [0, 0]
    clusterData.forEach(item => {
      const { lng, lat } = item.lnglat

      allLng += lng
      allLat += lat
    })
    const lngCenter = allLng / clusterData.length
    const latCenter = allLat / clusterData.length

    // 动态设置缩放级别
    const curZoom = curMap.value.getZoom()
    let targetZoom = curZoom + 3
    curMap.value.setZoomAndCenter(
        targetZoom,
        [lngCenter, latCenter],
        true
    )
  })
}

// 自定义聚合标记
const cMarker01 = new URL(`@/assets/images/project/blue.png`, import.meta.url).href;
const cMarker02 = new URL(`@/assets/images/project/green.png`, import.meta.url).href;
const cMarker03 = new URL(`@/assets/images/project/orange.png`, import.meta.url).href;
const cMarker04 = new URL(`@/assets/images/project/red.png`, import.meta.url).href;
const cMarker05 = new URL(`@/assets/images/project/darkRed.png`, import.meta.url).href;

const handlerRenderClusterMarker = (context) => {
  const { count } = context
  // 图标映射
  let curCMarker, size
  if (count <= 10) {
    curCMarker = cMarker01
    size = 48          // 原来 3248
  } else if (count <= 100) {
    curCMarker = cMarker02
    size = 48
  } else if (count <= 1000) {
    curCMarker = cMarker03
    size = 56          // 原来 3656
  } else if (count <= 10000) {
    curCMarker = cMarker04
    size = 64          // 原来 4864
  } else {
    curCMarker = cMarker05
    size = 64
  }

  // 数字样式:白色加粗 + 文字阴影,保证深浅背景都能看清
  const html = `
    <div class="custom-cmarker"
         style="width:${size}px;height:${size}px;
                background-image:url(${curCMarker});
                background-size:100% 100%;
                display:flex;align-items:center;justify-content:center;">
      <span style="color:#fff;font-size:16px;font-weight:bold;
                   text-shadow:0 0 2px #000;">${count}</span>
    </div>`
  context.marker.setContent(html)
}

function createContent() {
  const container = document.createElement("div");
  container.className = "info-window";

  const header = document.createElement("div");
  header.className = "info-header";
  header.textContent = '工单编号:'+selectedInfo.value.flowRecordId;

  const closeButton = document.createElement("span");
  closeButton.className = "close-btn";
  closeButton.textContent = "×";
  closeButton.onclick = () => infoWindow.value.close();
  header.appendChild(closeButton);

  const body = document.createElement("div");
  body.className = "info-body";

  // Status row
  const statusRow = document.createElement("div");
  statusRow.className = "status-row";

  const statusDot = document.createElement("span");
  const deviceColor = deviceStatusMakeColor(selectedInfo.value.projectPeriod);
  statusDot.className = "status-dot";
  statusDot.style.backgroundColor = deviceColor;

  const statusText = document.createElement("span");
  // statusText.style.color = deviceColor;
  statusText.textContent = getProjectType(selectedInfo.value.projectPeriod);

  statusRow.appendChild(statusDot);
  statusRow.appendChild(statusText);
  body.appendChild(statusRow);

  // Info rows
  const infoItems = [
    // { label: '工单编号', value: selectedInfo.value.flowRecordId },
    { label: '项目名称', value: selectedInfo.value.projectName },
    { label: '项目编号', value: selectedInfo.value.projectCode },
    { label: '项目类型', value: selectedInfo.value.projectType },
    { label: '工单类型', value: selectedInfo.value.flowId },
    { label: '项目阶段', value: getProjectType(selectedInfo.value.projectPeriod) },
    { label: '项目负责人', value: selectedInfo.value.projectManager },
    { label: '经纬度', value: selectedInfo.value.latLng },
  ];

  infoItems.forEach(item => {
    const row = document.createElement("div");
    row.className = "info-row";

    const label = document.createElement("span");
    label.className = "info-label";
    label.textContent = item.label + ":";

    const value = document.createElement("span");
    value.className = "info-value";
    value.textContent = item.value;

    row.appendChild(label);
    row.appendChild(value);
    body.appendChild(row);
  });

  container.appendChild(header);
  container.appendChild(body);
  return container;
}

// 非聚合状态
const marker03Img = new URL(`@/assets/images/project/position.png`, import.meta.url).href;
const marker04Img = new URL(`@/assets/images/project/position_down.png`, import.meta.url).href;
const marker05Img = new URL(`@/assets/images/project/position_green.png`, import.meta.url).href;
const marker01Img = new URL(`@/assets/images/project/position_warning.png`, import.meta.url).href;
const marker02Img = new URL(`@/assets/images/project/position_error.png`, import.meta.url).href;
const handlerRenderMarker = (context) => {
  const { projectPeriod } = context.data[0].info
  // 图标
  const iconSrc = statusIcon(projectPeriod)
  context.marker.setContent(
      `<img class="project-custom-marker-img" src="${iconSrc}" />`)

  // 关键:点击打开与原来样式一致的 InfoWindow
  context.marker.on('click', async (e) => {
    e.cancelBubble = true
    selectedInfo.value = context.data[0].info

    // 1. 放大并居中
    const pos = e.target.getPosition()
    curMap.value.setZoomAndCenter(20, [pos.lng, pos.lat], true)

    // 2. 弹窗
    infoWindow.value = new AMap.InfoWindow({
      isCustom: true,
      autoMove: true,
      offset: { x:40, y: -30 }
    })
    infoWindow.value.setContent(createContent())   // 复用原函数
    setTimeout(() => {
      infoWindow.value.open(curMap.value, [pos.lng, pos.lat])
    }, 100)
  })
}


onMounted(() => {
  // 注入样式
  const style = document.createElement('style')
  style.textContent = `
  .info-window {
    width: 600px;
    max-width: 90vw;
    height: auto;
    max-height: 80vh;
    padding: 0;
    background: #1e1e1e;
    border-radius: 12px;
    box-shadow: 0 8px 24px rgba(0,0,0,0.6);
    overflow: hidden;
    font-family: "Segoe UI", sans-serif;
    color: #f0f0f0;
    display: flex;
    flex-direction: column;
  }
  .info-header {
    background: linear-gradient(90deg, #5d96b5 0%, #93add5 100%);
    padding: 14px 18px;
    font-size: 16px;
    font-weight: 600;
    display: flex;
    justify-content: space-between;
    align-items: center;
  }
  .close-btn {
    cursor: pointer;
    font-size: 20px;
    color: #fff;
    transition: color 0.2s;
  }
  .close-btn:hover { color: #ffdddd; }
  .info-body {
    padding: 18px;
    display: flex;
    flex-direction: column;
    gap: 10px;
    overflow-y: auto;
  }
  .status-row {
    display: flex;
    align-items: center;
    margin-bottom: 10px;
  }
  .status-dot {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    margin-right: 8px;
  }
  .info-row {
    display: flex;
    justify-content: space-between;
    font-size: 14px;
    line-height: 1.6;
  }
  .info-label {
    color: #aaa;
    flex: 0 0 120px;
  }
  .info-value {
    color: #fff;
    flex: 1;
    text-align: left;
  }`
  document.head.appendChild(style)

  // 加载地图
  setTimeout(() => load_map(), 500)
})

onUnmounted(() => {
  // 清除地图相关
  if (curMap.value) {
    curMap.value.clearMap()
    curMap.value.destroy()
    curMap.value = null
    curAMap.value = null
  }
})
</script>

<style>
.border-box-content {
  display: flex;
  justify-content: center;
  align-items: center;
}

/* 移除高德地图相关元素 */
.dg,
.main,
.a {
  display: none;
}

.amap-logo,
.amap-copyright {
  display: none !important;
}

.amap-ranging-label {
  color: black;
}
.project-custom-marker-img {
  width: 60px;
  height: 44px;
}
</style>
<style scoped lang="scss">

* {
  margin: 0;
  padding: 0;
}
.custom-cmarker {
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 14px;
  color: #fff;
  background-size: contain;
}
</style>