「Leaflet」使用 Canvas 遮罩优化过量的 HTML Marker
场景
我曾在实际的项目中遇到过这样的情况,一片区域内有 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),性能非常的差。
临时解决方案
为了解决这个问题我先后使用了几个方法:
- 判断可视区域,隐藏区域外的文本元素。
- 拖拽/缩放时隐藏文本元素。
- 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 能提升不少的性能,但是该优化的地方还是得优化的,比如:
- 屏幕外的文本不需要绘制。
- 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