书接上文,本文将对代码结构进行简要的阐述和分析,详情见github代码仓库,在写本文的时候的git commit编号为b8095edfc50cf803b354da4b45edea0f527038ba。
提示:因为本人日常工作使用的都是typescript,所以该项目也用的是typescript。
typrscript的使用是大势所趋,包括vue3在内很多大名鼎鼎的前端框架都是使用typescipt写的, 所以掌握一定程度的typescript无论对目前的工作还是未来的提升都非常关键。
模块划分
主要分为四个类:
- BaseMap(地图容器)类
- Tile(地图瓦片)类
- MapCamera(摄像机)类
- FramerRender(帧渲染器)类 类之间的关系如下图所示。
BaseMap(地图容器)类
这个类是地图组件的入口,对整个地图组件进行管理,后续可以进行扩展,目前主要实现了以下几个功能:
- 对画布canvas进行初始化,获取context上下文
- 初始化摄像机类(MapCamera)及摄像机位置
- 初始化帧渲染器(FrameRender),并在瓦片加载成功后调用帧渲器对象来更新画布
- 加载瓦片(Tile类)并将维持一个cache表来复用已加载过的瓦片
// 加载瓦片方法
async loadTile(No: Vec3) {
const url = `http://mt1.google.cn/vt/lyrs=s&hl=zh-CN&x=${No.x}&y=${No.y}&z=${No.z}&s=Gali`;
const tile = new Tile(No, url);
// 将初始化好的瓦片放入缓存中,待渲染器使用
this.caches[`${No.z}-${No.x}-${No.y}`] = tile;
this.cacheArray.push(tile);
// map should render after new tile loaded
tile.readyPromise
.then(() => {
tile.status = Tile.TILE_STATUS.SUCCESS;
this.frame.update(this.camera);
})
.catch(() => {
tile.status = Tile.TILE_STATUS.FAIL;
});
}
Tile(地图瓦片)类
瓦片类主要负责对单个瓦片进行管理,主要功能就是加载对应编号的瓦片,将其绘制到一个没有挂载到dom树里面的canvas,核心代码如下:
// loadImage方法详见github仓库
// TILE_SIZE即为瓦片图片的像素尺寸,一般为256px
const image = await loadImage(sourceUrl);
this.canvas = document.createElement("canvas");
this.canvas.width = TILE_SIZE;
this.canvas.height = TILE_SIZE;
const context = this.canvas.getContext("2d");
notAssert(context, null);
context.drawImage(image, 0, 0, TILE_SIZE, TILE_SIZE);
瓦片的加载是由地图可视范围和高度决定的,因为我们使用了EPSG:3857坐标系,在3857坐标系中,横轴和竖轴的长度就是赤道长度,单位是米。在画布尺寸固定的情况下,结合前文所述的fov概念,我们能得出摄像机实际拍摄到的范围(米为单位),继而可以得到一个每像素代表多少米的比率。而不同层级的瓦片的宽度也是可以算出来的。
// 通过该方法,可以得到每层瓦片的所代表的实际宽度和分辨率(米/像素)
// SEMI_MAJOR_SXIS为赤道长度,即 20037508.3428 * 2
export const tileLevels: TileLevelInfo[] = new Array(20)
.fill(1)
.map((item, idx) => {
let scale = Math.pow(2, idx);
return {
width: SEMI_MAJOR_AXIS / scale,
level: idx,
ratio: SEMI_MAJOR_AXIS / scale / TILE_SIZE,
};
});
这里需要将瓦片单独拎出来单独做处理,而不是混在渲染器的代码里,主要是因为瓦片的分辨率一般都不是很大,像google地图的瓦片的只有256*256像素大小,为了保证整体的分辨率,一般需要同时加载几十个瓦片才能将画面铺满。那在帧渲染器渲染的时候,不可能阻塞等待这几十个瓦片完全加载,等瓦片加载完成,可能摄像机位置早就发生了变化,所以瓦片的加载需要异步处理。
MapCamera(摄像机)类
摄像机在地图组件中是一个虚拟的抽象概念,是我们模拟了一个摄像机并对其进行管理,这个类的功能相对简单且明确,摄像机主要管理的就是两个属性值,分别是fov和position(摄像机在3857坐标系中的位置),当摄像机的高度或位置发生变化的时候,调用帧渲染器的update方法,更新可视域范围和画布。
// 滚轮事件
this.map.container.addEventListener("mousewheel", (e: WheelEvent) => {
...
// 滚轮事件处理代码,详情见github
// 当滚轮事件处理完毕,更新好摄像机高度后,调用渲染器的更新方法
this.frame.update(this);
});
// 拖拽事件
this.map.container.addEventListener("mousedown", (e) => {
...
const moving = (e: MouseEvent) => {
...
// 拖拽事件处理代码,详情见github
// 当拖拽事件处理完毕,更新好摄像机位置后,调用渲染器的更新方法
this.frame.update(this);
...
};
...
});
EPSG:3857是一个平面坐标系,而我们为了实现地图组件的拉近拉远效果,需要在3857坐标系的基础上添加一个z轴来表示摄像机的高度信息。
// 摄像机设置位置的方法,参数使用日常更常见的4326坐标系
setView(pos: Vec3) {
const coors = proj4("EPSG:4326", "EPSG:3857", [pos.x, pos.y]);
this.position.x = coors[0];
this.position.y = coors[1];
this.position.z = pos.z;
this.frame.update(this);
}
FrameRender(帧渲染器)类
帧渲染类是地图组件的核心部件,当摄像机位置或高度发生变化时或者瓦片加载成功后,地图的可视范围也发生了变化,那么就需要调用帧渲染器重新渲染画布,对可见内容进行更新。
首先当需要渲染器更新渲染的时候,我们需要确定可视范围是多大:
update(camera: MapCamera) {
const { fov, position, map } = camera;
...
// 获取可视范围实际宽度,单位米
const metreWidth = Math.atan(fov / 2) * position.z * 2;
// 获取可视范围内的分辨率,实际宽度 / 画布宽度
const pixelRatio = metreWidth / canvasWidth;
// 下面的代码会有点绕,简单讲来就是将屏幕坐标转换为3857坐标
// 然后我们即可获得在3857坐标系下的可视范围
const west = position.x - metreWidth / 2 + SEMI_MAJOR_AXIS / 2;
const east = position.x + metreWidth / 2 + SEMI_MAJOR_AXIS / 2;
const north = SEMI_MAJOR_AXIS / 2 - (position.y + metreWidth / 2);
const south = SEMI_MAJOR_AXIS / 2 - (position.y - metreWidth / 2);
获取可视范围后,我们需要确定可视范围是要多少瓦片来填充(tileLevels在上文已有讲述):
// 根据分辨率(米/像素)获取层级信息
let levelInfo: TileLevelInfo = tileLevels[0];
for (let i = 0; i < tileLevels.length; i++) {
levelInfo = tileLevels[i];
if (pixelRatio > levelInfo.ratio) {
break;
}
}
const tileSize = levelInfo.width;
const westX = Math.ceil(west / tileSize) - 1;
const eastX = Math.ceil(east / tileSize) + 1;
const northY = Math.ceil(north / tileSize) - 1;
const southY = Math.ceil(south / tileSize) + 1;
确定好瓦片范围后,即可遍历所需的瓦片列表,如果瓦片已在缓存列表中,则直接使用缓存好的瓦片进行渲染,如果瓦片没在缓存列表中,则调用BaseMap的loadTile方法加载对应的瓦片,待瓦片加载完成,会在回调函数中再次调用渲染器的update方法,进行画布更新:
...
// 遍历瓦片列表
for (let x = westX; x <= eastX; x++) {
for (let y = northY; y <= southY; y++) {
let [tileX, tileY] = getXY(levelInfo, x, y);
const tileNo = new Vec3(tileX, tileY, levelInfo.level);
const key = `${tileNo.z}-${tileNo.x}-${tileNo.y}`;
// 判断瓦片是否已经加载完毕
if (caches[key]?.status === Tile.TILE_STATUS.SUCCESS) {
// 获取瓦片左上角在3857坐标系中的位置
let left = x * levelInfo.width - SEMI_MAJOR_AXIS / 2;
let top = SEMI_MAJOR_AXIS / 2 - y * levelInfo.width;
// 然后使用摄像机坐标减去瓦片坐标,获取瓦片距离摄像机位置的偏移量,以确定瓦片在画布中的位置
let offsetX = (left - position.x) / pixelRatio + canvasWidth / 2;
let offsetY = ((position.y - top) / pixelRatio + canvasHeight / 2);
const tile = caches[key];
// 根据偏移量绘制瓦片
context.drawImage(tile.canvas, offsetX, offsetY, size, size);
} else if (!caches[key]) {
map.loadTile(tileNo);
}
}
}