「Leaflet」使用 Canvas 遮罩优化过量的 HTML Marker

  |   0 评论   |   0 浏览   |   Erioifpud

场景

我曾在实际的项目中遇到过这样的情况,一片区域内有 2000 多个 polygon 区块,每个区块的中心都要显示区块的信息文本,信息文本是自定义的 marker:

L.marker(item.pos, {
  icon: L.divIcon({
    html: `<div style="font-size: 15px;font-weight: bold;color: #fff;">${
      item.text
    }</div>`,
    className: ""
  })
});

示意图

这也就意味着可视范围内总会出现“许多区块与许多 HTML 文本元素”,每次对地图的拖拽/缩放都会引起 HTML 元素的重排(layout),性能非常的差。

临时解决方案

为了解决这个问题我先后使用了几个方法:

  1. 判断可视区域,隐藏区域外的文本元素。
  2. 拖拽/缩放时隐藏文本元素。
  3. zoom level 到 17 以后才显示文本元素。

这几种方法都不尽人意,因为对元素的隐藏/显示操作,也会频繁引起重排,由于区块数量大,即使放大到 17 级,同屏内也会出现不少文本,并且在用户操作时,将文本信息隐藏,这反而会降低用户体验

分析

仔细分析一下,这些问题最根本的原因就是 HTML 元素过多了,不管你怎么操作,重排的开销都不降低多少,所以要解决问题,最重要的是得减少 HTML 元素

最好是能将这么多的文本画在一个元素里面,这里就可以用到 Canvas 了,使用一个 Canvas 覆盖在地图上,文本信息都在 Canvas 中绘制,它本身不会移动不会造成重排,最多也就是重绘它自己

效果

实现

<template>
  <canvas class="mask" ref="canvas"></canvas>
</template>

<script>
import L from "leaflet";

export default {
  props: {
    map: Object,
    event: Object,
    items: Array
  },
  watch: {
    event() {
      if (!this.event) {
        return;
      }
      this.draw();
    },
    map() {
      this.init();
    }
  },
  methods: {
    clear() {
      const canvas = this.$refs.canvas;
      const ctx = canvas.getContext("2d");
      ctx.clearRect(0, 0, canvas.width, canvas.height);
    },
    draw() {
      if (!this.map) {
        return;
      }
      this.clear();
      const canvas = this.$refs.canvas;
      const ctx = canvas.getContext("2d");
      const zoomLevel = this.map.getZoom();
      const isInView = center => {
        return this.map
          .getBounds()
          .pad(0.1)
          .contains(center);
      };
      if (zoomLevel < 13) {
        return;
      }
      this.items.forEach(item => {
        const center = L.latLng(item.pos);
        if (!isInView(center)) {
          return;
        }
        const pos = this.map.latLngToContainerPoint(center);
        // console.log(pos);
        ctx.fillStyle = "#000000";
        ctx.font = `bold ${zoomLevel - 3}px sans-serif`;
        ctx.textAlign = "center";
        ctx.fillText(item.text, pos.x, pos.y);
      });
    },
    init() {
      if (!this.map) {
        return;
      }
      const map = this.map;
      const canvas = this.$refs.canvas;
      const size = map.getSize();
      canvas.setAttribute("width", size.x);
      canvas.setAttribute("height", size.y);
      const ctx = canvas.getContext("2d");
      ctx.clearRect(0, 0, canvas.width, canvas.height);
    }
  }
};
</script>

<style scoped>
.mask {
  position: absolute;
  left: 0;
  top: 0;
  pointer-events: none;
  z-index: 20;
}
</style>

Canvas 的初始化

先得通过 map 的尺寸去设置 canvas 的分辨率,分辨率是通过 setAttribute 调整的,不要直接设置 style,因为这会直接将 canvas 进行拉伸,使内容变得模糊。

由于 canvas 是覆盖层,所以需要设置为绝对定位,并且手动指定一下 map 与他的 z-index,因为 Leaflet 会将 map 自动提前,如果不显式指定的话,即使 canvas 放在 map 标签后面,map 也会优先显示:

<div class="map" ref="map"></div>
<MaskCanvas :event="event" :map="map" :items="items"/>

最后需要给 canvas 设置 pointer-events: none;,因为他只是一个覆盖层,不应该影响背景中 map 的事件,要让事件穿透到 map 上

文本信息的绘制

说句题外话,其实在刚刚开始做的时候我还挺担心的,我怕“地理坐标换算成屏幕坐标”的这个流程得自己实现,后来发现 Leaflet 已经提供的相关的工具函数,方便了不少。

每次绘制之前都应该将 canvas 清空,要不然就会导致图像重叠,清空可以使用 clearRect 函数。

虽说 canvas 能提升不少的性能,但是该优化的地方还是得优化的,比如:

  1. 屏幕外的文本不需要绘制。
  2. zoom level 不高的时候(小于 13),可以隐藏文本,因为太密集了看起来也不清晰。
// 获得地图 zoom level
const zoomLevel = this.map.getZoom();
// 判断某个地理坐标是否在可视区域(+10%)内。
const isInView = center => {
  return this.map
    .getBounds()
    .pad(0.1)
    .contains(center);
};

接着就是将要显示的文本画在 canvas 上了,逻辑很简单:

const center = L.latLng(item.pos);
// 不在可视区域内的不画
if (!isInView(center)) {
  return;
}
// latLngToContainerPoint 是前文提到的“坐标转换”工具函数
// 返回 { x: number, y: number } 这样的结构
const pos = this.map.latLngToContainerPoint(center);
// 字体颜色
ctx.fillStyle = "#000000";
// 字体大小,可以通过 zoom level 计算得出,放得越大,字体也越大
ctx.font = `bold ${zoomLevel - 3}px sans-serif`;
// 字体居中
ctx.textAlign = "center";
// 在指定的 canvas 坐标上绘制文字
ctx.fillText(item.text, pos.x, pos.y);

初始化与绘制时机

watch: {
  event() {
    if (!this.event) {
      return;
    }
    this.draw();
  },
  map() {
    this.init();
  }
}

初始化依赖 map 实例,所以需要在 map 被初始化之后执行,init 中已经排除了 map 为空的情况。

draw 不完全依赖 event 实例,因为 event 在这里只是作为一个触发 draw 的条件,如果创建 map 时(L.map)开启了 zoomAnimation,那么地图初始化时会触发一次 zoom 事件,这时候也就能完成第一次绘制了,如果没有开启 zoomAnimation,那就得在 init 之后调用一次 draw

完整代码

项目链接

可以分别注释 Example.vue 中的 58 行或 MaskCanvas.vue 中的 19 行,来查看两种绘制方式的性能。


标题:「Leaflet」使用 Canvas 遮罩优化过量的 HTML Marker
作者:Erioifpud
地址:https://blog.doiduoyi.com/articles/1586073919037.html

评论

发表评论