前言
最近需要研究个新功能,地图的点位太多,需要进行聚合渲染,通过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>
效果图
缩放的时候,点位就会自动的聚合
浮窗卡片显示
给每个点位加上弹窗,在这基础上我调整了一下代码
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>
效果图
因为我是通过聚合后的点位数据来渲染每个图层,所以卡片也是可以聚合的
但是对于我来说,世纪难题来了,我想要实现卡片的防重叠问题,试了好多次,通过碰撞检测都不行,有实现过类似需求的掘友么,求求求!!!
有什么第三方的碰撞检测的库,或者有什么方案可以实现呢,达到卡片不重叠,可以智能的发布在四周