【基于Leaflet和Canvas绘图的前端大量栅格数据渲染】

2023-11-19

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个部分:

  1. 计算出渲染范围,左下角和右上角屏幕坐标
  2. 计算出每一个坐标的渲染起点
  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 实现大数据量渲染 (一) - 简书

Leaflet中文文档

ArcGIS API实现气象数据可视化(esri公开课笔记)_跳墙网

ArcGIS API for Javascript 4.X扩展canvas在三维地图实现热力图

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

【基于Leaflet和Canvas绘图的前端大量栅格数据渲染】 的相关文章