最近需要开发一个地图聚类分析的功能,每一层缩放等级,标点有一定样式要求,于是我想到了大学时参加数学建模竞赛用过的Kmeans聚类算法。
1.K-means和K-means++聚类算法
K-means聚类算法实现原理
- 随机选择k个数据点作为k个簇的中心点,也可以是不在数据集里面的k个随机点。
- 计算点到k个簇的中心点的距离,选择距离最小的那个中心点的簇,将点归为该簇。
- 计算出每个簇所有点的平均位置作为新的中心点。
- 重复2,3步骤,直到点无需修改所在簇。
K-means++聚类算法实现原理 初始中心点很影响聚类簇的迭代次数,k-means++就是为了优化初始中心点。
- 从数据点中随机选择一个中心。
- 对于每个数据点x,计算D(x),即x与已经选择的最接近中心之间的距离。
- 使用加权概率分布随机选择一个新的数据点作为新的中心,其中选择点 x 的概率与D(x)^2成正比。
- 重复步骤2和3,直到选择了K个中心。
- 剩下的迭代过程与k-means聚类算法一致。
2.skmeans的使用
Github地址:https://github.com/solzimer/skmeans/
skmeans计算数据上的单向和多维k-means聚类
- 第一个参数
data
:要聚类的值的单维或多维数组。对于单向数据,采用简单数组[1,2,3.....,n]
的形式。对于多维数据,需要一个NxM数组[[1,2],[2,3]。。。。[n,m]]
- 第二个参数
k
:簇的数量 - 第三个参数
centroids
:初始中心点,如果没有提供,可以选择kmrand
随机生成初始中心点,kmpp
使用kmeans++
生成初始中心点。
js
//生成随机数据
const data = [];
for (let i = 0; i < 3000; i++) {
data.push([Math.random() * 180, Math.random() * 90]);
}
//10个簇
const res1 = skmeans(data, 10, 'kmrand');
const res2=skmeans(data, 10, 'kmpp');
console.log(res1, res2);
可以看到kmeans++
生成的中心点迭代次数比较少,所以推荐使用kmeans++
3.在高德地图上绘制对海量点进行聚类分析
数据来源自高德地图-全国粤菜分布情况,数据量大约有10万个点。
可以看到数据中经纬度的是字符串,skmeans聚类算法只能对数值进行,因此需要转换一下。
js
const originData = await fetch('cuisine.json')
.then((res) => res.json())
.then((res) => res.features);
this.originData = originData;
//转换数据格式成数组
this.data = originData.map((item) => {
item.geometry.coordinates[0] = Number(item.geometry.coordinates[0]);
item.geometry.coordinates[1] = Number(item.geometry.coordinates[1]);
return item.geometry.coordinates;
});
- 计算聚类点,统计每个簇的点数,并排序,方便绘制的时候,包含点数比较大的聚类点在上方。
js
calcKmeans(k) {
const result = skmeans(this.data, k, 'kmpp');
const count = {};
//统计每个簇的点数
result.idxs.forEach((clusterId) => {
count[clusterId] = (count[clusterId] || 0) + 1;
});
let min = Number.MAX_SAFE_INTEGER;
let max = Number.MIN_SAFE_INTEGER;
const countList = [];
for (let k in count) {
const vv = count[k];
min = Math.min(min, vv);
max = Math.max(max, vv);
countList.push({ idx: k, num: vv });
}
//排序簇包含的点数
countList.sort((a, b) => a.num - b.num);
const len = max - min;
return {
centroids: result.centroids,
count,
countList,
min,
max,
len,
//获取大小值
lerpSize: (val) => {
return this.minSize + ((val - min) / len) * this.size;
},
//获取透明度
lerpOpacity: (val) => {
return this.minOpacity + ((val - min) / len) * this.opacity;
}
};
}
- 提前计算好聚类结果,并缓存,减少重复计算,避免在render里实时计算,会十分消耗性能。
js
if (localStorage.getItem('kmeans50')) {
this.result1 = JSON.parse(localStorage.getItem('kmeans50'));
this.result1.lerpSize = (val) => {
return this.minSize + ((val - this.result1.min) / this.result1.len) * this.size;
};
this.result1.lerpOpacity = (val) => {
return this.minOpacity + ((val - this.result1.min) / this.result1.len) * this.opacity;
};
} else {
this.result1 = this.calcKmeans(50);
localStorage.setItem('kmeans50', JSON.stringify(this.result1));
}
if (localStorage.getItem('kmeans250')) {
this.result2 = JSON.parse(localStorage.getItem('kmeans250'));
this.result2.lerpSize = (val) => {
return this.minSize + ((val - this.result2.min) / this.result2.len) * this.size;
};
this.result2.lerpOpacity = (val) => {
return this.minOpacity + ((val - this.result2.min) / this.result2.len) * this.opacity;
};
} else {
this.result2 = this.calcKmeans(250);
localStorage.setItem('kmeans250', JSON.stringify(this.result2));
}
- 给高德地图添加自定义canvas图层
js
const canvas = document.createElement('canvas');
this.canvas = canvas;
const customLayer = new AMap.CustomLayer(canvas, {
zooms: [3, 20],
zIndex: 120
});
customLayer.render = this.onRender.bind(this);
customLayer.setMap(this.map);
自定义canvas图层通过调用render函数绘制聚类点
- 画布调整大小
js
const canvas = this.canvas;
const map = this.map;
//调整画布大小
const retina = AMap.Browser.retina;
const size = map.getSize();
const width = size.width;
const height = size.height;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
if (retina) {
//高清适配
width *= 2;
height *= 2;
}
canvas.width = width;
canvas.height = height;
- 不同缩放等级对应的设置聚类点不同的颜色和簇数量结果
js
const zoom = map.getZoom();
let color = 'green';
let result = this.result1;
if (zoom < 8) {
color = 'green';
result = this.result1;
} else if (zoom >= 8 && zoom <= 15) {
color = '#00BBFF';
result = this.result2;
}
- 当地图缩放等级小于等于15时绘制不同透明度和大小的聚类点
js
if (zoom <= 15) {
//关闭信息提示框
this.infoWindow.close();
ctx.shadowColor = 'rgba(0,0,0,0.5)';
//当地图缩放等级小于等于15时绘制聚类点
for (let i = 0; i < result.countList.length; i += 1) {
const item = result.countList[i];
const center = result.centroids[item.idx];
//经纬度转换成像素坐标
const pos = map.lngLatToContainer(center);
//簇包含的数量
const v = item.num;
let r = result.lerpSize(v);
//根据分辨率放缩
if (retina) {
pos = pos.multiplyBy(2);
r *= 2;
}
//只绘制可视范围内的聚类点
if (pos.x >= -r && pos.y >= -r && pos.x <= width + r && pos.y <= height + r) {
//根据簇包含的数量绘制不同透明度和大小的圆
ctx.globalAlpha = result.lerpOpacity(v);
ctx.shadowBlur = r;
ctx.beginPath();
ctx.fillStyle = color;
ctx.arc(pos.x, pos.y, r, 0, PI2);
ctx.fill();
//绘制簇包含点数量的文本
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.shadowBlur = 0;
ctx.fillStyle = 'white';
const fontSize = r * 0.7;
ctx.font = fontSize + 'px serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(v + '', pos.x, pos.y);
}
}
}
当缩放等级为4.35时,可以全国粤菜聚类点分布情况,可以看到广东地区明显很多。
当缩放等级为8.14且中心点在广东附近时,可以看到珠三角地区粤菜分布很密集。
- 当地图缩放等级大于15时绘制原始数据点所在的图标,并收集可视范围内的点,用于点击事件监听
js
else {
//当地图缩放等级大于15时绘制原始数据点所在的图标
//收集可视范围内的点,用于map事件代理点击动作,选择对应的点
const showPoints = {};
this.showPoints = showPoints;
for (let i = 0; i < this.data.length; i++) {
const center = this.data[i];
//经纬度转换成像素坐标
const pos = map.lngLatToContainer(center);
//只绘制可视范围内的聚类点
if (
pos.x >= -this.halfWidth &&
pos.y >= -this.halfHeight &&
pos.x <= width + this.halfWidth &&
pos.y <= height + this.halfHeight
) {
//利用toFixed截取经纬度,创建一个不同经纬度作为关键词的对象集合,方便分区域获取点
const id = `${center[0].toFixed(2)}-${center[1].toFixed(2)}`;
if (showPoints[id]) {
showPoints[id].push({ center, idx: i });
} else {
showPoints[id] = [{ center, idx: i }];
}
ctx.drawImage(
this.img,
//需偏移图标大小一半的位置
pos.x - this.halfWidth,
pos.y - this.halfHeight,
this.imgWidth,
this.imgHeight
);
}
}
}
- 通过map顶层事件代理,给坐标点添点击事件监听,弹出信息框
js
const infoWindow = new AMap.InfoWindow({
content: `info`, //传入字符串拼接的 DOM 元素
anchor: 'bottom-center',
isCustom: true,
offset: new AMap.Pixel(0, 5)
});
this.infoWindow = infoWindow;
//map顶层事件代理
this.map.on('click', (ev) => {
const zoom = this.map.getZoom();
//根据图标大小调整
const MIN = 0.000005 * zoom;
if (zoom > 15) {
const { lat, lng } = ev.lnglat;
console.log(lat, lng);
const fLat = Number(lat.toFixed(2)),
fLng = Number(lng.toFixed(2));
const points = [];
//点击坐标附近的点
for (let i = -0.01; i <= 0.01; i += 0.01) {
for (let j = -0.01; j <= 0.01; j += 0.01) {
const id = `${fLng + i}-${fLat + j}`;
if (this.showPoints[id]) {
points.push(...this.showPoints[id]);
}
}
}
let selectItem;
let distance = Number.MAX_SAFE_INTEGER;
for (let i = 0; i < points.length; i++) {
const p = points[i];
//查找最接近的点
if (Math.abs(p.center[0] - lng) <= MIN && Math.abs(p.center[1] - lat) <= MIN) {
const d = Math.sqrt((p.center[0] - lng) ^ (2 + (p.center[1] - lat)) ^ 2);
if (d <= distance) {
selectItem = p;
distance = d;
}
}
}
if (selectItem) {
infoWindow.setContent(`<div class="info-box">
<div>第${selectItem.idx + 1}个数据</div>
<div>经度:${selectItem.center[0]}</div>
<div>纬度:${selectItem.center[1]}</div>
</div>
<div class="info-box-triangle"><span></span></div>`);
infoWindow.open(this.map, selectItem.center);
}
}
});
注意:
- 收集可视范围区域坐标点时,可以利用
toFixed
截取小数点后几位经纬度,创建一个不同经纬度作为关键词的对象集合,方便分区域获取点,避免遍历全部可视范围内的点,如果点分布特别密集,缩放等级很大,即需要区域细分比较多,可以增加小数点后几位的精度。 - 点击坐标最靠近点的范围
MIN
要根据图标大小对应调整,即图标像素显示在地图上占据的经纬度范围,MIN
范围判断是为了排除无关点,减少计算,也可以不进行MIN
范围判断,直接遍历所有附近的经纬度点,找出最小距离的点即可。
4.最终效果
弄了三个缩放层级,初始缩放层级小于8,50个绿色聚类点,第二个缩放层级8~15,250个聚类点,第三缩放层级大于15,原始坐标点图标,最终效果如下:
可以根据自己的需要增加或减少不同缩放等级聚类簇的数量以及对应的样式。
Github地址
https://github.com/xiaolidan00/my-earth
参考