openlayer-点位渲染及点位聚合的使用(三)

39 阅读6分钟

前言

最近需要研究个新功能,地图的点位太多,需要进行聚合渲染,通过openlayer的文档查找,发现是具有这个功能的,通过Cluster集群功能来实现

文档:OpenLayers v10.7.0 API - Class: Cluster

地图的构建

这边就直接贴出代码吧,可直接使用,我这边是随机获取点位,Vue3的环境下

import { Map, View, Overlay } from 'ol'
import TileLayer from 'ol/layer/Tile'
import XYZ from 'ol/source/XYZ'
import { Fill, Stroke, Style, Text } from 'ol/style'
import { Vector as VectorSource } from 'ol/source'
import VectorLayer from 'ol/layer/Vector'
import Feature from 'ol/Feature'
import { boundingExtent } from 'ol/extent'
import Point from 'ol/geom/Point'
import Cluster from 'ol/source/Cluster'
import CircleStyle from 'ol/style/Circle'
import { onMounted, ref } from 'vue'

const map = ref()
const initMap = () => {
  map.value = new Map({
    target: 'map',
    // 设置地图图层
    layers: [
      new TileLayer({
        source: new XYZ({
          url: '申请天地图获取'
        }),
        zIndex: 5
      })
    ],
    view: new View({
      center: [113.5, 23.25],
      zoom: 9.6,
      maxZoom: 14,
      minZoom: 8,
      projection: 'EPSG:4326'
    })
  })
}
const clustersLayer = ref()
const clustersMap = () => {
  const count = 50
  const features = new Array(count)

  // ✅ 广州大致经纬度范围(可微调)
  const minLng = 113.1 // 左西经
  const maxLng = 113.9 // 右东经
  const minLat = 22.8 // 下南纬
  const maxLat = 23.5 // 上北纬

  for (let i = 0; i < count; ++i) {
    const lon = Math.random() * (maxLng - minLng) + minLng
    const lat = Math.random() * (maxLat - minLat) + minLat

    // ✅ 创建 EPSG:4326 经纬度点(与 view.projection 一致)
    features[i] = new Feature({
      geometry: new Point([lon, lat]),
      data: {
        name: '点位' + i,
        lon: lon,
        lat: lat
      }
    })
  }

  const source = new VectorSource({
    features: features
  })

  const clusterSource = new Cluster({
    distance: 40, // 聚合距离(像素),可后续调整
    minDistance: 20, // 最小聚合距离
    source: source
  })

  const styleCache: any = {}
  clustersLayer.value = new VectorLayer({
    source: clusterSource,
    style: function (feature) {
      const size = feature.get('features').length
      let style = styleCache[size]
      if (!style) {
        // 根据数量设置不同样式(可选增强)
        const radius = Math.max(8, Math.min(size * 0.3, 20)) // 数量越多圆越大

        style = new Style({
          image: new CircleStyle({
            radius: radius,
            stroke: new Stroke({
              color: '#fff',
              width: 2
            }),
            fill: new Fill({
              color: 'rgba(255, 0, 0, 0.7)' // 红色填充更明显
            })
          }),
          text: new Text({
            text: size.toString(),
            fill: new Fill({
              color: '#fff'
            }),
            font: 'bold 12px sans-serif'
          })
        })
        styleCache[size] = style
      }
      return style
    },
    zIndex: 10
  })

  map.value.addLayer(clustersLayer.value)
}

const listenMapEvent = () => {
  // 地图鼠标移动事件
  // ✅ 获取鼠标下任意点的 data(兼容单点 & 聚合点)
  const getDataFromFeature = (feature: any) => {
    // 情况1:如果是聚合点(有 features 数组)
    if (feature.get('features') && feature.get('features').length > 0) {
      return feature.get('features')[0].get('data') // ← 读第一个原始点的 data
    }
    // 情况2:如果是原始点(未被聚合,直接渲染)
    return feature.get('data')
  }
  map.value.on('pointermove', (ev: any) => {
    const pixel = ev.pixel
    let data = null

    map.value.forEachFeatureAtPixel(pixel, (feature: any) => {
      data = getDataFromFeature(feature)
      return !!data // 找到就停止遍历
    })

    map.value.getTargetElement().style.cursor = data ? 'pointer' : ''
  })
  map.value.on('click', (e: any) => {
    clustersLayer.value.getFeatures(e.pixel).then((clickedFeatures: any) => {
      if (clickedFeatures.length) {
        // Get clustered Coordinates
        const features = clickedFeatures[0].get('features')
        if (features.length > 1) {
          const extent = boundingExtent(
            features.map((r: any) => r.getGeometry().getCoordinates())
          )
          map.value
            .getView()
            .fit(extent, { duration: 1000, padding: [50, 50, 50, 50] })
        }
      }
    })
  })
}
onMounted(() => {
  initMap()
  clustersMap()
  listenMapEvent()
})
</script>
<template>
  <div class="container">
    <div id="map"></div>
  </div>
</template>

<style lang="less" scoped>
.container {
  width: 100%;
  height: 100vh;
  background: #000;
  display: flex;
  flex-direction: column;
  #map {
    flex: 1;
  }
}
.info-card {
  background-color: rgba(0, 0, 0, 0.8);
  border-radius: 5px;
  padding: 10px;
  color: white;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
  z-index: 1000;
}
.card-title {
  font-weight: bold;
  margin-bottom: 5px;
}
.card-content {
  font-size: 12px;
}
</style>

效果图

image.png 缩放的时候,点位就会自动的聚合 image.png

浮窗卡片显示

给每个点位加上弹窗,在这基础上我调整了一下代码

import { Map, View, Overlay } from 'ol'
import TileLayer from 'ol/layer/Tile'
import XYZ from 'ol/source/XYZ'
import { Fill, Stroke, Style, Text } from 'ol/style'
import { Vector as VectorSource } from 'ol/source'
import VectorLayer from 'ol/layer/Vector'
import Feature from 'ol/Feature'
import { boundingExtent } from 'ol/extent'
import Point from 'ol/geom/Point'
import Cluster from 'ol/source/Cluster'
import CircleStyle from 'ol/style/Circle'
import { onMounted, ref, nextTick } from 'vue'

const map = ref()
const clustersLayer = ref()
const cardOverlays = ref<any[]>([])

// ----------------------------
// 初始化地图
// ----------------------------
const initMap = () => {
  map.value = new Map({
    target: 'map',
    layers: [
      new TileLayer({
        source: new XYZ({
          url: '天地图'
        }),
        zIndex: 5
      })
    ],
    view: new View({
      center: [113.5, 23.25],
      zoom: 9.6,

      projection: 'EPSG:4326'
    })
  })
}

// ----------------------------
// 创建聚合图层 & 数据
// ----------------------------
const clustersMap = () => {
  const count = 25
  const features = []
  const minLng = 113.1
  const maxLng = 113.9
  const minLat = 22.8
  const maxLat = 23.5

  for (let i = 0; i < count; ++i) {
    const lon = Math.random() * (maxLng - minLng) + minLng
    const lat = Math.random() * (maxLat - minLat) + minLat
    features.push(
      new Feature({
        geometry: new Point([lon, lat]),
        data: {
          name: '点位' + i,
          lon: lon,
          lat: lat,
          stationid: 'ST' + String(i).padStart(4, '0'),
          datetime: new Date(Date.now() - Math.random() * 1000000000)
        }
      })
    )
  }

  const source = new VectorSource({ features })

  const clusterSource = new Cluster({
    distance: 40,
    minDistance: 20,
    source: source
  })

  const styleCache: any = {}
  clustersLayer.value = new VectorLayer({
    source: clusterSource,
    style: function (feature) {
      const size = feature.get('features').length
      let style = styleCache[size]
      if (!style) {
        const radius = Math.max(8, Math.min(size * 0.3, 20))
        style = new Style({
          image: new CircleStyle({
            radius,
            stroke: new Stroke({ color: '#fff', width: 2 }),
            fill: new Fill({ color: 'rgba(255, 0, 0, 0.7)' })
          }),
          text: new Text({
            text: size.toString(),
            fill: new Fill({ color: '#fff' }),
            font: 'bold 12px sans-serif'
          })
        })
        styleCache[size] = style
      }
      return style
    },
    zIndex: 10
  })

  map.value.addLayer(clustersLayer.value)
  // ✅ 关键:等 clusterSource 加载完成再创建卡片(避免 getFeatures() 为空)
  clusterSource.on('change', () => {
    if (clusterSource.getState() === 'ready') {
      nextTick(() => createInfoCards())
    }
  })
}

// ----------------------------
// ✅ 创建卡片(带箭头 + 防重叠 + 无抖动)
// ----------------------------
const createInfoCards = () => {
  cardOverlays.value.forEach((o) => map.value?.removeOverlay(o))
  cardOverlays.value = []

  const clusterFeatures = clustersLayer.value?.getSource().getFeatures() || []
  if (clusterFeatures.length === 0) return

  // 步骤1:预生成所有卡片 DOM + 计算像素位置(用于碰撞检测)
  const pixelRects: Array<{
    x: number
    y: number
    width: number
    height: number
  }> = []
  const overlays: any[] = []

  clusterFeatures.forEach((feature: any) => {
    const coords = feature.getGeometry().getCoordinates()
    const size = feature.get('features').length
    const firstFeature = feature.get('features')[0]
    const data = firstFeature.get('data')

    // ✅ 1. 创建 SVG + 文字容器(支持长箭头)
    const container = document.createElement('div')
    container.className = 'info-card'
    container.innerHTML = `
      <svg class="card-arrow" width="100%" height="40" viewBox="0 0 100 40" preserveAspectRatio="none">
        <line x1="50" y1="0" x2="50" y2="30" stroke="#6cd2fe" stroke-width="2" stroke-linecap="round"/>
        <polygon points="45,25 50,35 55,25" fill="#6cd2fe"/>
      </svg>
      <div class="card-content">
        <div class="card-title">${
          size > 1 ? `共 ${size} 个点` : data.name
        }</div>
        <div class="card-item">站号:${data.stationid}</div>
        <div class="card-item">时间:${new Date(
          data.datetime
        ).toLocaleString()}</div>
      </div>
    `

    // ✅ 2. 获取该卡片在屏幕上的像素位置(用于碰撞检测)
    const pixel = map.value?.getPixelFromCoordinate(coords) || [0, 0]
    const width = 180,
      height = 90 // 卡片宽高(px)
    pixelRects.push({
      x: pixel[0] - width / 2,
      y: pixel[1] - height - 20, // 箭头向上延伸 20px
      width,
      height
    })

    // ✅ 3. 创建 Overlay(定位在 coords,但由 SVG 控制箭头方向)
    const overlay = new Overlay({
      element: container,
      positioning: 'center-center',
      offset: [0, -65], // 箭头总长 ≈ 45px(SVG 高度 + margin)
      position: coords,
      stopEvent: false
    })

    overlays.push(overlay)
  })

  // ✅ 步骤3:添加到地图
  overlays.forEach((overlay) => {
    map.value?.addOverlay(overlay)
    cardOverlays.value.push(overlay)
  })
}

// ----------------------------
// 监听地图事件(优化版:只响应缩放,不监听平移)
// ----------------------------
const listenMapEvent = () => {
  // ✅ 只在缩放时更新卡片(避免平移抖动)
  map.value?.getView().on('change:resolution', () => {
    clearTimeout((window as any).__cardUpdateTimer)
    ;(window as any).__cardUpdateTimer = setTimeout(() => {
      createInfoCards()
    }, 120)
  })

  // 鼠标悬停变手型(可选)
  map.value.on('pointermove', (ev: any) => {
    const pixel = ev.pixel
    let hasFeature = false
    map.value.forEachFeatureAtPixel(pixel, () => {
      hasFeature = true
      return true
    })
    map.value.getTargetElement().style.cursor = hasFeature ? 'pointer' : ''
  })

  // 点击放大(保持原有逻辑)
  map.value.on('click', (e: any) => {
    clustersLayer.value.getFeatures(e.pixel).then((clickedFeatures: any) => {
      if (clickedFeatures.length) {
        const features = clickedFeatures[0].get('features')
        if (features.length > 1) {
          const extent = boundingExtent(
            features.map((r: any) => r.getGeometry().getCoordinates())
          )
          map.value
            .getView()
            .fit(extent, { duration: 1000, padding: [50, 50, 50, 50] })
        }
      }
    })
  })
}

onMounted(() => {
  initMap()
  clustersMap()
  listenMapEvent()
})
</script>

<template>
  <div class="container">
    <div id="map"></div>
  </div>
</template>

<style lang="less" scoped>
.container {
  width: 100%;
  height: 100vh;
  background: #000;
  #map {
    width: 100%;
    height: 100%;
  }
}

// ✅ 带箭头的信息卡片
:deep(.info-card) {
  position: relative;
  background-color: rgba(0, 40, 80, 0.95);
  color: white;
  border-radius: 8px;
  padding: 12px 16px;
  font-size: 13px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
  min-width: 180px;
  z-index: 1000;
  pointer-events: auto;

  .card-arrow {
    position: absolute;
    top: 100%;
    left: 50%;
    margin-left: -8px;
    width: 0;
    height: 0;
    border-left: 8px solid transparent;
    border-right: 8px solid transparent;
    border-top: 8px solid rgba(0, 40, 80, 0.95); /* 与背景一致 */
  }

  .card-content {
    .card-title {
      font-weight: bold;
      margin-bottom: 6px;
      font-size: 14px;
      color: #6cd2fe;
    }
    .card-item {
      line-height: 1.6;
      font-size: 12px;
      color: #eee;
    }
  }
}
</style>

效果图

image.png

因为我是通过聚合后的点位数据来渲染每个图层,所以卡片也是可以聚合的

image.png

但是对于我来说,世纪难题来了,我想要实现卡片的防重叠问题,试了好多次,通过碰撞检测都不行,有实现过类似需求的掘友么,求求求!!!

有什么第三方的碰撞检测的库,或者有什么方案可以实现呢,达到卡片不重叠,可以智能的发布在四周