手撸一个地图组件02

406 阅读6分钟

书接上文,本文将对代码结构进行简要的阐述和分析,详情见github代码仓库,在写本文的时候的git commit编号为b8095edfc50cf803b354da4b45edea0f527038ba

提示:因为本人日常工作使用的都是typescript,所以该项目也用的是typescript。

typrscript的使用是大势所趋,包括vue3在内很多大名鼎鼎的前端框架都是使用typescipt写的, 所以掌握一定程度的typescript无论对目前的工作还是未来的提升都非常关键。

模块划分

主要分为四个类:

  1. BaseMap(地图容器)类
  2. Tile(地图瓦片)类
  3. MapCamera(摄像机)类
  4. FramerRender(帧渲染器)类 类之间的关系如下图所示。

BaseMap(地图容器)类

这个类是地图组件的入口,对整个地图组件进行管理,后续可以进行扩展,目前主要实现了以下几个功能:

  1. 对画布canvas进行初始化,获取context上下文
  2. 初始化摄像机类(MapCamera)及摄像机位置
  3. 初始化帧渲染器(FrameRender),并在瓦片加载成功后调用帧渲器对象来更新画布
  4. 加载瓦片(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);
      }
    }
  }

仓库地址