1. 需求
有包含30万坐标点的json文件,每个坐标点包含经度、纬度、行值、列值、数值,现需要根据数值分级进行不同颜色的显示,并在地图的正确位置进行渲染。最终效果如下:
2. 环境和工具
2.1 使用Edge、Chrome
实测采用Chromium内核的浏览器在坐标计算和Canvas渲染速度上要快出非常多(对比Firefox),下图前7列为Firefox,最后一列为Edge,同样为7万个点,快了一倍不止。
2.2 PixJS的使用
Pixjs:HTML5创建引擎。最快、最灵活的 2D WebGL 渲染器。
Pixjs是一个2D的游戏引擎,它真的很快,非常快。本文只用到了最基础的两个功能,矩形绘制和图片绘制。渲染30万个矩形平均时间在400ms左右。
PixJS官网:https://pixijs.com/
PixJS手册中文网站:https://aitrade.ga/pixi.js-cn/index.html
3. 实现思路
3.1 地图引擎分析
Canvas绘图技术在地图上实现数据渲染,无非要解决两个问题。一是拖动跟随,二是缩放重绘。不同地图引擎在地图绘制上使用的技术和方法不同,比如Leaflet使用SVG,而ArcGIS使用WebGL。同时,他们对Canvas图层的处理方式也不同。
以Leaflet为例,在地图加载完成后,手动添加Canvas图层,并且在左上角绘制一个矩形。
当我们拖动地图一段距离后再次观察canvas的位置,发现它的定位并没有发生变化,但是却不在屏幕中心了。说明它跟随父级div的位置变动而变动,且起点坐标位于屏幕左上角。
了解canvas的变换规律,可以让我们在定位图片的过程中有的放矢。
3.2 渲染方式选择
一种方式是直接将栅格渲染至canvas中,在地图缩放后重新渲染。另一种方式是,先在离屏canvas中渲染出需要的效果,保存为webp格式或png格式的图片,之后的处理都在该图片上进行。这里采用方式二。
3.3 思路梳理
- 初始化地图(监听地图移动事件)
- 初始化PixJS(添加canvas)
- 生成webp格式图片
- 在地图上正确定位webp图
- 响应地图拖动和缩放
4. 编码实现
4.1 监听地图事件
map.on('moveend', () => {
// 获取地图拖动后,相对于地图原点坐标原点的偏移值
const offset_x = map._mapPane._leaflet_pos.x;
const offset_y = map._mapPane._leaflet_pos.y;
// 获取地图相对于上次的偏移值‘,此时this.offset_x是上次的偏移值
// 首次偏移后,偏移值‘ = 相对于地图原点坐标原点的偏移值
if (this.offset_x) {
this.offset_x_count = offset_x - this.offset_x;
} else {
this.offset_x_count = offset_x;
}
if (this.offset_y) {
this.offset_y_count = offset_y - this.offset_y;
} else {
this.offset_y_count = offset_y;
}
// 记录本次偏移值
this.offset_x = offset_x;
this.offset_y = offset_y;
})
需要在地图初始化之后便开始记录偏移量(地图的拖动),map._mapPane._leaflet_pos.x
获取的是始终是地图相对于初始化状态的偏移量,因此每次移动后的偏移增量需要额外计算。
4.2 初始化PixiJS
initPixjs() {
let left = -this.map._mapPane._leaflet_pos.x + 'px';
let top = -this.map._mapPane._leaflet_pos.y + 'px';
let app = new PIXI.Application({
width: document.body.clientWidth,
height: document.body.clientHeight,
backgroundAlpha: 0
});
this.pixjsApp = app;
this.canvas = app.view;
this.canvas.style.position = "absolute";
this.canvas.style.top = top;
this.canvas.style.left = left;
let parent = document.getElementsByClassName("leaflet-pane leaflet-overlay-pane")[0];
parent.appendChild(app.view);
}
pixi会自动生成canvas元素,我们需要将其添加到父级元素之下。在添加时,地图可能未移动过,也可能移动过,为了保证canvas起点位于屏幕左上角,在添加前获取地图的偏移值并将其负值作为canvas的绝对定位值。
4.3 经纬度转屏幕坐标
Leaflet并没有现成的方法将一个经纬度坐标转换到屏幕坐标,但是从文章:Leaflet 如何把一个坐标转换到屏幕上中我们可以得知,Leaflet 加载完以后,会有一个 map-pane
的div元素,里面包含了所有的图层,PixelOrigin
就是 map-pane
这个容器最左上角的位置。我们可以将某个经纬度转换为投影坐标,再减去像素原点的坐标,就能得到该点在屏幕中的具体位置,这也是为什么我们需要把canvas元素的起点固定在屏幕左上角,如此一来计算出来的屏幕坐标就是在canvas中的坐标。
/**
* 经纬度转屏幕坐标
* @param lon 经度
* @param lat 纬度
* @param zoom 缩放等级
* @returns {{x: number, y: number}} 屏幕坐标
*/
lngLatToScreen(lon, lat, zoom) {
let point = this.$refs.map.CRS_4490.latLngToPoint(L.latLng({lon: lon, lat: lat}), zoom);
let origin = this.$refs.map.map.getPixelOrigin();
return {
x: (point.x - origin.x),
y: (point.y - origin.y)
}
}
/**
* 全部栅格点坐标转换
* @param points 栅格点(行,列,积水值)
* @param bounds 显示范围
* @param zoom
*/
pointsToScreen(points, bounds, zoom) {
const screen_left_bottom = this.lngLatToScreen(bounds.left_bottom[0], bounds.left_bottom[1], zoom);
const screen_right_top = this.lngLatToScreen(bounds.right_top[0], bounds.right_top[1], zoom);
// 计算每行/列总像素值,保存,作为渲染时画布的大小
let row_pixels = (screen_right_top.x - screen_left_bottom.x);
let col_pixels = (screen_left_bottom.y - screen_right_top.y);
// 计算行像素步长和列像素步长
let step_x = row_pixels / bounds.col_count; //在canvas的一行中,也就是x轴方向,每个栅格应该占有的像素 = 一行的总像素/总列数
let step_y = col_pixels / bounds.row_count; //在canvas的一列中,也就是y轴方向,每个栅格应该占有的像素 = 一列的总像素/总行数
// 渲染起点坐标
let origin_x = screen_left_bottom.x;
let origin_y = screen_right_top.y;
this.origin_x = origin_x;
this.origin_y = origin_y;
// 计算所有栅格点相对于起点,在canvas中的渲染坐标
let canvas_points = {};
for (let i = 1; i <= 9; i++) {
let part_result = [];
let part_points = points['level' + i];
for (let point of part_points) {
// 行数确定y轴坐标,列数确定x轴坐标
let x = point[1] * step_x;
let y = (bounds.row_count - point[0] - 1) * step_y;
part_result.push([x, y]);
}
canvas_points['level' + i] = part_result;
}
return {
canvas_points: canvas_points,
render_width: row_pixels,
render_height: col_pixels,
pixel_width: step_x,
pixel_height: step_y,
canvas_width: screen_right_top.x,
canvas_height: screen_left_bottom.y,
origin_x: origin_x,
origin_y: origin_y
}
},
坐标文件格式不同,处理的步骤便不同。这里主要关注以下3个部分:
- 计算出渲染范围,左下角和右上角屏幕坐标
- 计算出每一个坐标的渲染起点
- 计算出每一个坐标的渲染宽高
4.4 生成webp图
/**
* 生成离屏图webp图
* 在拖动时只需渲染webp图,缩放等级变化后需要重新生成
* @param zoom 缩放等级
* @param offset_x 地图x轴偏移量
* @param offset_y 地图y轴偏移量
* @returns webp图 Base64格式
*/
generatePic(zoom, offset_x, offset_y) {
let result = this.pointsToScreen(this.points, this.bounds, zoom);
let canvas_points = result['canvas_points'];
let render_width = result['render_width'];
let render_height = result['render_height'];
let pixel_width = result['pixel_width'];
let pixel_height = result['pixel_height'];
let origin_x = result['origin_x'];
let origin_y = result['origin_y'];
const app = new PIXI.Application({
width: render_width,
height: render_height,
backgroundAlpha: 0,
preserveDrawingBuffer: true,
autoDensity: true,
autoStart: true,
});
const graphic = new PIXI.Graphics();
app.stage.addChild(graphic);
// console.time('渲染计时');
for (let i = 1; i <= 9; i++) {
let part_points = canvas_points['level' + i];
graphic.beginFill(this.colors[i]);
part_points.forEach(point => {
graphic.drawRect(point[0], point[1], pixel_width, pixel_height)
});
}
app.render();
// console.timeEnd('渲染计时');
this.imageBase64 = app.view.toDataURL('image/webp', 1.0);
// 将图片加载到地图上
this.addPicToMap(this.imageBase64, origin_x, origin_y, offset_x, offset_y);
// pixjs使用后,销毁webgl_content
app.renderer.gl.getExtension('WEBGL_lose_context').loseContext();
app.destroy({
removeView: true,
stageOptions: {
children: true
}
});
return this.imageBase64;
}
这里使用PixiJS的Graphic类来绘制所有的矩形,这里的绘制并没有在屏幕中显示,而是在内存中进行。完成绘制后将canvas中的图像保存为base64编码,图片选择webp格式,相对于png格式它的文件体积更小,同时画质差距不大。离屏图的效果如下:
4.5 将webp图添加到地图中
addPicToMap(image_base64, origin_x, origin_y, offset_x, offset_y) {
const pic = PIXI.Sprite.from(image_base64);
pic.x = origin_x + offset_x;
pic.y = origin_y + offset_y;
this.pixjsPic = pic;
this.pixjsApp.stage.addChild(pic);
}
前面我们提到,在添加图片之前,地图就可能移动过,因此图片在canvas中的位置也需要考虑地图的偏移,offset_x, offset_y参数在调用4.4函数时传入,其数值是当前地图偏移量。
4.6 响应地图事件
/**
* 更新图片在canvas中的位置
* @param pic pixjs的Sprite类 包含一张图片
* @param x x轴偏移量
* @param y y轴偏移量
*/
changePicPosition(pic, x, y) {
// 根据地图偏移数值设定图片位置
pic.x = pic.x + x;
pic.y = pic.y + y;
}
map.on('moveend', () => {
if (!this.pixjsPic) {
return;
}
// 1.若缩放等级变化,根据现有坐标点,重新生成图片并加载到地图上
let sub = this.zoom - map.getZoom()
if (sub !== 0) {
this.generatePic(map.getZoom(), map._mapPane._leaflet_pos.x, map._mapPane._leaflet_pos.y);
this.zoom = map.getZoom();
return;
}
// 保持canvas在屏幕中央
// canvas相对于图层的的绝对定位值变化方向,是地图偏移的反方向
this.canvas.style.left = -this.offset_x + 'px';
this.canvas.style.top = -this.offset_y + 'px';
// 改变图片在canvas中的坐标
// 图片坐标的变化方向,和地图相对于上次的偏移值‘方向相同
this.changePicPosition(this.pixjsPic, this.offset_x_count, this.offset_y_count);
})
正常显示图片后,最后要做的就是在每次地图拖动结束后设置canvas的位置,同时也需要更新其中图片的位置,来保证1.canvas起点始终位于屏幕左上角 2.canvas中的图片始终与地图元素重合。
参考
arcgis 与 pixi.js 实现大数据量渲染 (一) - 简书