高德地图自定义canvas实现聚类分析

最近需要开发一个地图聚类分析的功能,每一层缩放等级,标点有一定样式要求,于是我想到了大学时参加数学建模竞赛用过的Kmeans聚类算法。

1.K-means和K-means++聚类算法

K-means聚类算法实现原理

  1. 随机选择k个数据点作为k个簇的中心点,也可以是不在数据集里面的k个随机点。
  2. 计算点到k个簇的中心点的距离,选择距离最小的那个中心点的簇,将点归为该簇。
  3. 计算出每个簇所有点的平均位置作为新的中心点。
  4. 重复2,3步骤,直到点无需修改所在簇。

K-means++聚类算法实现原理 初始中心点很影响聚类簇的迭代次数,k-means++就是为了优化初始中心点。

  1. 从数据点中随机选择一个中心。
  2. 对于每个数据点x,计算D(x),即x与已经选择的最接近中心之间的距离。
  3. 使用加权概率分布随机选择一个新的数据点作为新的中心,其中选择点 x 的概率与D(x)^2成正比。
  4. 重复步骤2和3,直到选择了K个中心。
  5. 剩下的迭代过程与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);
        }
      }
    });

注意:

  1. 收集可视范围区域坐标点时,可以利用toFixed截取小数点后几位经纬度,创建一个不同经纬度作为关键词的对象集合,方便分区域获取点,避免遍历全部可视范围内的点,如果点分布特别密集,缩放等级很大,即需要区域细分比较多,可以增加小数点后几位的精度。
  2. 点击坐标最靠近点的范围MIN要根据图标大小对应调整,即图标像素显示在地图上占据的经纬度范围,MIN范围判断是为了排除无关点,减少计算,也可以不进行MIN范围判断,直接遍历所有附近的经纬度点,找出最小距离的点即可。

4.最终效果

弄了三个缩放层级,初始缩放层级小于8,50个绿色聚类点,第二个缩放层级8~15,250个聚类点,第三缩放层级大于15,原始坐标点图标,最终效果如下:

可以根据自己的需要增加或减少不同缩放等级聚类簇的数量以及对应的样式。

Github地址

https://github.com/xiaolidan00/my-earth

参考

相关推荐
计算机毕设定制辅导-无忧学长10 分钟前
HTML 新手入门:从零基础到搭建第一个静态页面(二)
前端·javascript·html
烛阴1 小时前
JavaScript 函数对象与 NFE:你必须知道的秘密武器!
前端·javascript
eli9601 小时前
node-ddk,electron 开发组件
前端·javascript·electron·node.js·js
老K(郭云开)2 小时前
最新版Chrome浏览器加载ActiveX控件技术--allWebPlugin中间件一键部署浏览器扩展
前端·javascript·chrome·中间件·edge
老K(郭云开)2 小时前
allWebPlugin中间件自动适应Web系统多层iframe嵌套
前端·javascript·chrome·中间件
还是鼠鼠3 小时前
Node.js 的模块作用域和 module 对象详细介绍
前端·javascript·vscode·node.js·web
拉不动的猪3 小时前
刷刷题36(uniapp高级实际项目问题-1)
前端·javascript·面试
勘察加熊人3 小时前
angular打地鼠
前端·javascript·angular.js
柒@宝儿姐3 小时前
如何判断一个项目用的是哪个管理器
前端·javascript·vue.js·vue3
爱看书的小沐4 小时前
【小沐学Web3D】three.js 加载三维模型(vue3)
javascript·vue·vue3·webgl·three.js·opengl·web3d