一、概要
OpenLayers自带的Cluster方法可以根据距离将点要素进行聚类,有助于提升地图的可读性,但其不能根据要素属性进行分类聚合。
常规的聚类效果:
通过阅读源码,发现只需要将Cluster类的方法略微改造即可实现基于要素属性聚类(支持多属性)。
二、完整代码:基于地图缩放级别的点聚类和样式更新
<template>
<!--地图-->
<div ref="mapContainer" class="mapContainer" id="mapContainer"></div>
</template>
<script lang="ts" setup>
import { onMounted, ref, shallowRef } from 'vue'
import { View, Map as OLMap, Feature } from "ol"
import { fromLonLat } from 'ol/proj'
import TileLayer from 'ol/layer/Tile'
import { defaults as defaultControls } from "ol/control"
import { Vector as VectorLayer } from 'ol/layer'
import VectorSource from 'ol/source/Vector'
import { Circle as CircleStyle, Style, Stroke, Text, Icon } from "ol/style";
import Fill from 'ol/style/Fill'
import GeoJSON from 'ol/format/GeoJSON'
import type { FeatureLike } from 'ol/Feature'
import { Geometry, Point } from 'ol/geom'
import { XYZ } from 'ol/source'
import {
buffer,
createEmpty,
createOrUpdateFromCoordinate,
} from 'ol/extent';
import { add as addCoordinate, scale as scaleCoordinate } from 'ol/coordinate';
import type { Coordinate } from 'ol/coordinate';
import { getUid } from 'ol/util';
// 改为你自己的GeoJson数据地址
const pointDataURL = './geoJson/10000'
// 地图容器
const mapContainer = shallowRef<HTMLDivElement>()
// 地图对象
const map = shallowRef<OLMap>()
// 矢量图层
let vectorLayer: VectorLayer<VectorSource> | null
// 聚类图层
let clusterLayer: VectorLayer<VectorSource> | null
// 聚类结果
let clusterFeatures: Feature<Point>[] = []
// 聚类的属性字段(我这里是color或者src)
const clusterField: string[] = ['color']
// 结束聚类的分辨率层级
const zoomMax = 8
// 触发聚类的最大像素距离
const distance = 50
/**
* @description 创建地图实例
* @param {Document | DocumentId} target 地图容器
* @returns 地图对象
*/
const createMap = function (target: HTMLElement | string,): OLMap {
// 创建地图
const map = new OLMap({
target,
layers: [
new TileLayer({
source: new XYZ({
url: "http://t0.tianditu.gov.cn/DataServer?T=vec_w&x={x}&y={y}&l={z}&tk=99a8ea4a53c8553f6f3c565f7ffc15ec",
crossOrigin: 'anonymous',
wrapX: true
})
})
],
controls: defaultControls({
zoom: false, // 不显示放大放小按钮
rotate: false, // 不显示指北针控件
attribution: false, // 不显示右下角的地图信息控件
}).extend([]),
view: new View({
projection: 'EPSG:3857', // 坐标系EPSG:4326或EPSG:3857
zoom: 4, // 打开页面时默认地图缩放级别
maxZoom: 20,
minZoom: 1, // 地图缩放最小级别
center: fromLonLat([121.5, 25]), // 需要转到墨卡托坐标系
constrainResolution: true, // 自动缩放到距离最近的一个整数级别
})
})
return map
}
/**
* @description 新建图层(检测到同名图层直接获取)
* @param map 地图实例
* @param layerName 图层名
* @param getStyle feature样式
* @returns
*/
function getVectorLayer(map: OLMap, layerName: string, getStyle: Function): VectorLayer<VectorSource> {
let vectorLayer = map.getLayers().getArray().find(layer => layer.get('name') === layerName) as VectorLayer<VectorSource>;
if (!vectorLayer) {
vectorLayer = new VectorLayer({
source: new VectorSource({ wrapX: true, features: [] }),
style: function (feature: FeatureLike) {
return getStyle(feature)
}
});
vectorLayer.set('name', layerName);
map.addLayer(vectorLayer);
}
return vectorLayer;
}
/**
* @description >>>>>>>> 添加矢量点(可追加数据)
* @param { Map } map 地图对象
* @param { string} layerName 图层名
* @param { any } pointData 点据集合
*/
function addPointVector(
map: OLMap,
layerName: string,
pointData: any) {
if (!map || !pointData) { return }
// 获取矢量图层
vectorLayer = getVectorLayer(map, layerName, getStyle) as VectorLayer<VectorSource<Point>>
// 添加数据源
let features = new GeoJSON({ dataProjection: 'EPSG:4326', featureProjection: 'EPSG:3857' }).readFeatures(pointData)
let source = new VectorSource({ features: features }) as VectorSource<Point>
// 按分辨率加载聚类/矢量图层
trigger(map, source, layerName)
map.getView().on("change:resolution", () => {
trigger(map, source, layerName)
});
}
/**
* 根据地图缩放级别触发图层更新。
* @param {OLMap} map - 地图实例
* @param {VectorSource<Point>} source - 数据源
* @param {string} layerName - 图层名称
* @description 根据地图的缩放级别加载相应的图层:低于指定缩放级别时加载聚类图层,高于该级别时加载矢量图层。
*/
function trigger(map: OLMap, source: VectorSource<Point>, layerName: string) {
const clusterLayerName = `${layerName}_cluster`;
const zoom = map.getView()!.getZoom()!;
// 清空现有数据源
clearSource();
// 根据缩放级别加载不同的图层
if (zoom < zoomMax) {
// 加载聚类图层
addCluster(source, clusterLayerName);
} else {
// 加载矢量图层
vectorLayer?.getSource()?.addFeatures(source.getFeatures());
}
}
/**
* @description 该函数会清空聚类特征数组,并清除矢量图层和聚类图层的数据源。
*/
function clearSource() {
// 清空存储聚类要素的数组
clusterFeatures = [];
// 清除矢量图层的数据源
vectorLayer?.getSource()?.clear();
// 清除聚类图层的数据源
clusterLayer?.getSource()?.clear();
}
/**
* @description 聚类加载
* @param layerName 图层名
*/
function addCluster(source: VectorSource<Point>, layerName: string) {
// 创建聚类层
clusterLayer = getVectorLayer(map.value!, layerName + '_cluster', getStyle) as VectorLayer<VectorSource<Point>>
// 聚类数据源
const clusterFeatures = getClusterFeatures(map.value!, source)
if (clusterFeatures) {
clusterLayer?.getSource()?.addFeatures(clusterFeatures)
}
}
/**
* 聚类特征的入口方法。
* @param {OLMap} map - OpenLayers 地图对象
* @param {VectorSource<Point>} source - 包含点特征的数据源
* @return {Feature<Point>[]} - 聚类后的特征列表
*/
function getClusterFeatures(map: OLMap, source: VectorSource<Point>): Feature<Point>[] {
// 创建一个空范围对象,用于存储特征的空间范围
const extent = createEmpty();
// 计算地图上的距离(选项中的距离乘以地图视图的分辨率)
const mapDistance = distance * map.getView()!.getResolution()!;
// 获取数据源中的所有特征
const features = source.getFeatures();
// 记录已处理的特征的唯一标识符
const clustered = {} as Record<string, boolean>;
// 初始化存储聚类特征的数组
const clusterFeatures: Feature<Point>[] = [];
// 遍历所有特征
for (const feature of features) {
const uid = getUid(feature);
// 如果特征还未处理
if (!(uid in clustered)) {
// 获取特征的几何体
const geometry = feature.getGeometry();
if (geometry) {
// 更新范围对象为特征几何体的坐标
createOrUpdateFromCoordinate(geometry.getCoordinates(), extent);
// 扩展范围以包含周围区域
buffer(extent, mapDistance, extent);
// 查找在扩展范围内的所有特征,并排除已处理的特征
const neighbors = source.getFeaturesInExtent(extent).filter(neighbor => {
// 检查邻近特征是否与当前特征相似
const isSimilar = clusterField?.every(field => neighbor.get(field) === feature.get(field));
// 如果相似且未处理过,则标记为已处理
if (isSimilar && !(getUid(neighbor) in clustered)) {
clustered[getUid(neighbor)] = true;
return true;
}
return false;
});
// 创建聚类特征并添加到列表中
if (neighbors.length > 0) {
clusterFeatures.push(createCluster(neighbors));
}
}
}
}
// 返回包含聚类特征的数组
return clusterFeatures;
}
/**
* 聚合一组特征的几何体为一个新的特征,计算其质心。
* @param {Array<Feature>} features 输入的特征数组
* @return {Feature<Point>} 聚类后的特征,包含质心位置
* @protected
*/
function createCluster(features: Feature[]): Feature<Point> {
// 初始化质心坐标为 [0, 0]
const centroid = [0, 0];
// 从最后一个特征开始遍历,避免在删除特征时影响迭代
for (let i = features.length - 1; i >= 0; --i) {
const geometry = features[i].getGeometry() as Point;
if (geometry) {
// 将几何体坐标累加到质心坐标中
addCoordinate(centroid, geometry.getCoordinates());
} else {
// 移除没有几何体的特征
features.splice(i, 1);
}
}
// 计算质心坐标的平均值
scaleCoordinate(centroid, 1 / features.length);
// 创建表示质心的点几何体
const geometry = new Point(centroid);
if (features.length === 1) {
// 如果只有一个特征,直接返回该特征
return new Feature({
...features[0].getProperties(),
geometry
}) as Feature<Point>;
} else {
// 返回包含质心几何体和原始特征的聚类特征
return new Feature({
geometry,
features,
...features[0].getProperties()
});
}
}
/**
* @description >>>>>>>> 全局样式函数,用于矢量要素
* @param { Feature<Geometry> } feature 矢量要素
* @returns { Array<Style> } 要素样式数组
*/
// 全局样式缓存
const styleCache: Record<string, Array<Style>> = {};
/**
* 获取给定要素的样式
* @param { Feature<Geometry> } feature 矢量要素
* @returns { Array<Style> } 要素样式数组
*/
function getStyle(feature: Feature<Geometry>): Array<Style> {
// 从要素中获取样式属性,设置默认值
const color = feature.get('color') ?? 'rgba(255, 255,255,0)'; // 默认颜色为黑色
const rotation = feature.get('rotation') ?? 0; // 默认旋转角度为0
const size = feature.get('features') ? (feature.get('features') as Feature<Geometry>[]).length : undefined; // 聚类的个数
const src = (feature.get('src') ? feature.get('src') : 'defalut.png')
// 定义样式数组
const styles: Array<Style> = [];
// 为单个要素定义圆圈样式
styles.push(new Style({
image: new Icon({
scale: 0.5,
src,
color,
rotation,
crossOrigin: 'anonymous',
})
}));
// 如果有聚类要素,定义聚类标记样式
if (size !== undefined) {
styles.push(new Style({
geometry: clusterGeometry(feature), // 聚类几何形状
image: new CircleStyle({
radius: 8,
stroke: new Stroke({
color: 'yellow',
}),
fill: new Fill({
color,
}),
}),
text: new Text({
font: "12px Calibri, sans-serif", // 设置字体
text: size.toString(), // 显示聚类个数
fill: new Fill({
color: 'yellow',
}),
}),
}));
}
// 使用唯一的缓存键存储样式
const cacheKey = `${color}-${size}`;
if (!styleCache[cacheKey]) {
styleCache[cacheKey] = styles;
}
return styleCache[cacheKey];
}
/**
* @description 计算聚类角标在屏幕上的位置
* @param { Feature<Geometry> } feature 矢量要素
* @returns { Point } 聚类角标的新位置
*/
const clusterGeometry = function (feature: Feature<Geometry>): Point {
// 获取几何对象和坐标
const geometry = feature.getGeometry() as Point;
let coordinate = geometry.getCoordinates() as Coordinate;
// 将坐标转换为屏幕像素
const pixel = map.value?.getPixelFromCoordinate(coordinate);
// 如果成功获取像素,则从中心向左、下偏移10像素
if (pixel) {
// 使用安全访问操作符和默认值避免 undefined 错误
const [x, y] = pixel;
const newCoordinate = map.value?.getCoordinateFromPixel([x + 10, y + 10]);
if (newCoordinate) {
coordinate = newCoordinate;
}
}
return new Point(coordinate);
}
onMounted(async () => {
map.value = createMap(mapContainer.value!);
// 打开GeoJson
const response = await fetch(`${pointDataURL}.json`);
const point = await response.json();
// 加载Point
addPointVector(map.value, 'pointLayer', point);
});
</script>
<style lang="scss">
#mapContainer {
position: absolute;
top: 0;
z-index: 0;
width: 100%;
height: 100%;
}
</style>
聚类最大像素距离50时,基于图标(src字段)的聚类效果:
聚类最大像素距离50时,基于颜色(color字段)的聚类效果:
三、核心代码:基于距离和要素属性聚类
/**
* 聚类特征的入口方法。
* @param {OLMap} map - OpenLayers 地图对象
* @param {VectorSource<Point>} source - 包含点特征的数据源
* @return {Feature<Point>[]} - 聚类后的特征列表
*/
function getClusterFeatures(map: OLMap, source: VectorSource<Point>): Feature<Point>[] {
// 创建一个空范围对象,用于存储特征的空间范围
const extent = createEmpty();
// 计算地图上的距离(选项中的距离乘以地图视图的分辨率)
const mapDistance = distance * map.getView()!.getResolution()!;
// 获取数据源中的所有特征
const features = source.getFeatures();
// 记录已处理的特征的唯一标识符
const clustered = {} as Record<string, boolean>;
// 初始化存储聚类特征的数组
const clusterFeatures: Feature<Point>[] = [];
// 遍历所有特征
for (const feature of features) {
const uid = getUid(feature);
// 如果特征还未处理
if (!(uid in clustered)) {
// 获取特征的几何体
const geometry = feature.getGeometry();
if (geometry) {
// 更新范围对象为特征几何体的坐标
createOrUpdateFromCoordinate(geometry.getCoordinates(), extent);
// 扩展范围以包含周围区域
buffer(extent, mapDistance, extent);
// 查找在扩展范围内的所有特征,并排除已处理的特征
const neighbors = source.getFeaturesInExtent(extent).filter(neighbor => {
// 检查邻近特征是否与当前特征相似
const isSimilar = clusterField?.every(field => neighbor.get(field) === feature.get(field));
// 如果相似且未处理过,则标记为已处理
if (isSimilar && !(getUid(neighbor) in clustered)) {
clustered[getUid(neighbor)] = true;
return true;
}
return false;
});
// 创建聚类特征并添加到列表中
if (neighbors.length > 0) {
clusterFeatures.push(createCluster(neighbors));
}
}
}
}
// 返回包含聚类特征的数组
return clusterFeatures;
}
/**
* 聚合一组特征的几何体为一个新的特征,计算其质心。
* @param {Array<Feature>} features 输入的特征数组
* @return {Feature<Point>} 聚类后的特征,包含质心位置
* @protected
*/
function createCluster(features: Feature[]): Feature<Point> {
// 初始化质心坐标为 [0, 0]
const centroid = [0, 0];
// 从最后一个特征开始遍历,避免在删除特征时影响迭代
for (let i = features.length - 1; i >= 0; --i) {
const geometry = features[i].getGeometry() as Point;
if (geometry) {
// 将几何体坐标累加到质心坐标中
addCoordinate(centroid, geometry.getCoordinates());
} else {
// 移除没有几何体的特征
features.splice(i, 1);
}
}
// 计算质心坐标的平均值
scaleCoordinate(centroid, 1 / features.length);
// 创建表示质心的点几何体
const geometry = new Point(centroid);
if (features.length === 1) {
// 如果只有一个特征,直接返回该特征
return new Feature({
...features[0].getProperties(),
geometry
}) as Feature<Point>;
} else {
// 返回包含质心几何体和原始特征的聚类特征
return new Feature({
geometry,
features,
...features[0].getProperties()
});
}
}
主要思路:
- getClusterFeatures 函数:
- 计算某点的缓冲范围,在范围内查找所有的邻近点
- 验证特征并记录具有相同特征的邻近点
- 使用createCluster聚合相同特征的邻近点
- createCluster 函数:
- 计算特征邻近点的中心位置
- 累加所有点的特征并创建新的聚类特征
四、数据说明
本文使用的矢量点数据为标准的GeoJson数据,数据下方自取,属性字段包括:
- name:名称/编码,类型为 string | number
- rotationx:旋转角度,范围0~1
- src: 图片的路径
- color:字符串颜色
数据样例:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"rotation": 0.97,
"name": "0700",
"src": "/img/ship.png",
"color": "brown"
},
"geometry": {
"coordinates": [
140.39957836095004,
58.443654693841836
],
"type": "Point"
}
},
]
}
// "src": "/img/ship.png"
// "src": "/img/plane.png",
// "src": "/img/city.png",
// "src": "/img/car.png",
// "src": "/img/house.png"',
// 图片需要自己找,放在public/img内