用canvas绘制兰伯特投影地图

最近有个需求,需要使用自然资源部的中国地势图作为底图绘制地图,不是墨卡托投影,没搞过这种投影,emmm~头大!

找了一圈资料,了解到该图使用了兰伯特等角圆锥投影,有个标准,那可以研究研究!

1. 兰伯特等角圆锥投影

兰勃特投影是由德国数学家兰勃特(J.H.Lambert)拟定的正形圆锥投影。有两种:

  • 等角圆锥投影。设想用一个正圆锥切于或割于球面,应用等角条件将地球面投影到圆锥面上,然后沿一母线展开成平面。投影后纬线为同心圆圆弧,经线为同心圆半径。没有角度变形,经线长度比和纬线长度比相等。适于制作沿纬线分布的中纬度地区中、小比例尺地图。国际上用此投影编制1∶100万地形图和航空图;
  • 等积方位投影。设想球面与平面切于一点,按等积条件将经纬线投影于平面而成。

常用的兰伯特等角圆锥投影,简单来说

  • lat1,lat2作为切割线,该线旋转一圈,变成一个圆锥体斜表面
  • lng0,lat0作为原点,展开圆锥斜面,做二维坐标系
  • 取球体上某个点lng,lat,转换为该二维坐标系的x,y坐标点

2. 使用proj4.js实现地图投影

proj4.js用于将点坐标从一个坐标系转换到另一个坐标系统,包括基准转换。官网:http://proj4js.org/

proj4.js源于PROJ,参数一致,可以参考PROJ的官网 https://proj.org/en/stable/

  • 安装proj4.js
sh 复制代码
pnpm add proj4
  • 参数配置,可以通过epsg.io搜索常用的地图投影标准。

我们常用的球面墨卡托投影Spherical Mercator对应编号EPSG:3857

EPSG:3857的投影参数设置

js 复制代码
proj4.defs("EPSG:3857","+proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +wktext +no_defs +type=crs");
  • proj4.defs(投影名称,投影参数)定义投影
  • +proj=merc投影类型为merc墨卡托投影,可设置参数可以通过PROJ搜索

墨卡托投影参数

  • +lat_ts=0:真实比例的纬度。定义比例不扭曲的纬度。默认为0.0
  • +lon_0=0:投影中心的经度。默认为0.0
  • +x_0=0:偏移x,单位米。默认为0.0
  • +y_0=0:偏移y,单位米。默认为0.0
  • +a=6378137 +b=6378137: 椭球长半径和短半径,单位米。

使用定义的投影

ts 复制代码
//将经纬度转换为对应投影坐标
proj4(projection).forward([lng,lat])

proj4('EPSG:3857').forward([116,39])
//[12913060.932019735,4721671.572580107]

//将投影坐标转换为经纬度 
proj4(projection).inverse([12913060.932019735,4721671.572580107]);
//[116.00000000000001, 38.999999999999986]

投影坐标与经纬度相互转换有些许偏差是正常的,不必介意。

3. 定义兰伯特投影

PROJ 兰伯特投影参数

  • +lat_1=<value>第一条纬线纬度
  • +lat_2=<value>第一条纬线纬度
  • +lat_0=<value>原点纬度
  • +lon_0=<value>原点经度

使用epsg.io搜索中国兰伯特投影,结果只有中国南海的兰伯特投影

虽然参数配置可能对不上,但是可以作为参考

js 复制代码
proj4.defs("EPSG:3415","+proj=lcc +lat_0=21 +lon_0=114 +lat_1=18 +lat_2=24 +x_0=500000 +y_0=500000 +ellps=WGS72 +towgs84=0,0,1.9,0,0,0.814,-0.38 +units=m +no_defs +type=crs");

从别人的博客中了解到中国兰伯特投影参数(China Lambert)

  • 第一条纬线:25度
  • 第二条纬线:47度
  • 投影原点:经度105,纬度0

校验投影参数

用Canvas绘制底图和中国边界,调整参数,两者边界尽量重叠。

ts 复制代码
drawGeo() {
    const image = this.image!;
    const data = this.data;
    const canvas = this.canvas;
    const ctx = this.ctx;

    proj4.defs(
      this.projection,
      `+proj=lcc +lat_0=${data.lat0} +lon_0=${data.lng0} +lat_1=${data.lat1} +lat_2=${data.lat2} +x_0=${
        data.x0 * 1000
      } +y_0=${data.y0 * 1000} +ellps=WGS72 +towgs84=0,0,1.9,0,0,0.814,-0.38 +units=m +no_defs +type=crs`
    );

    ctx.clearRect(0, 0, canvas.width, canvas.height);
    //绘制底图
    ctx.drawImage(image, 0, 0, this.imageWidth * this.scaleVal, this.imageHeight * this.scaleVal);
    ctx.strokeStyle = 'red';
    ctx.lineWidth = 2;
    //中国边界
    travelGeo(ChinaJson, (path: Array<[number, number]>) => {
      ctx.beginPath();
      const p0 = this.lnglat2px(path[0]);
      ctx.moveTo(p0[0], p0[1]);
      for (let i = 1; i < path.length; i++) {
        const p = this.lnglat2px(path[i]);
        ctx.lineTo(p[0], p[1]);
      }
      ctx.closePath();
      ctx.stroke();
    });
  }

经纬度转投影坐标

ts 复制代码
lnglat2px(a: [number, number]) {
    const xy = proj4(this.projection)
      .forward(a)
      .map((t) => t / this.data.zoom);

    return [this.scaleVal * (xy[0] + this.imageWidth * 0.5), this.scaleVal * (this.imageHeight - xy[1])];
  }

使用lil-gui修改参数并重绘

ts 复制代码
  gui() {
    createGui(
      [
        {
          //原点纬度
          name: 'lat0',
          type: 'number',
          min: 0,
          max: 30,
          step: 0.1,
          onChange: this.drawGeo.bind(this)
        },
        {
          //原点经度
          name: 'lng0',
          type: 'number',
          min: 40,
          max: 120,
          step: 0.1,
          onChange: this.drawGeo.bind(this)
        },
        {
          //第一条纬线纬度
          name: 'lat1',
          type: 'number',
          min: 0,
          max: 90,
          step: 0.1,
          onChange: this.drawGeo.bind(this)
        },
        {
          //第二条纬线纬度
          name: 'lat2',
          type: 'number',
          min: 0,
          max: 90,
          step: 0.1,
          onChange: this.drawGeo.bind(this)
        },
        {
          //左偏移量
          name: 'x0',
          type: 'number',
          min: -2000,
          max: 2000,
          step: 1,
          onChange: this.drawGeo.bind(this)
        },
        {
          //上偏移量
          name: 'y0',
          type: 'number',
          min: -2000,
          max: 2000,
          step: 1,
          onChange: this.drawGeo.bind(this)
        },
        {
          //缩放等级
          name: 'zoom',
          type: 'number',
          min: 1000,
          max: 2000,
          step: 1,
          onChange: this.drawGeo.bind(this)
        }
      ],
      this.data
    );
  }

按照此中国兰伯特投影参数已经尽量调整了,边界之间还是存在较大的偏差,决定放弃此参数配置,自己定义!

ts 复制代码
{
    lat0: 0,
    lng0: 105,
    lat1: 25,
    lat2: 47,
    x0: -65,
    y0: -1835,
    zoom: 1324
  }

最终一点点修改后,得到一个最符合的兰伯特投影参数设置。

ts 复制代码
{
    lat0: 0,
    lng0: 110,
    lat1: 21,
    lat2: 56.8,
    x0: 350,
    y0: -1835,
    zoom: 1300
  }

4. 用canvas绘制地图

4.1 封装中国地势图兰伯特投影

根据缩放大小,中国地势图图片大小来计算经纬度与像素坐标之间的转换。

ts 复制代码
class ChinaLambertProj {
  projection = 'China Lambert';
  //兰伯特投影参数
  data = {
    lat0: 0,
    lng0: 110,
    lat1: 21,
    lat2: 56.8,
    x0: 350,
    y0: -1835,
    zoom: 1300
  };
  //中国地势图图片宽高
  imageWidth = 100;
  imageHeight = 100;
  //缩放比例
  scaleVal = 0.2;
  constructor() {
    const data = this.data;
    proj4.defs(
      this.projection,
      `+proj=lcc +lat_0=${data.lat0} +lon_0=${data.lng0} +lat_1=${data.lat1} +lat_2=${data.lat2} +x_0=${
        data.x0 * 1000
      } +y_0=${data.y0 * 1000} +ellps=WGS72 +towgs84=0,0,1.9,0,0,0.814,-0.38 +units=m +no_defs +type=crs`
    );
  }
  //设置缩放比例
  setScale(v: number) {
    this.scaleVal = v;
  }
  //经纬度转像素
  lnglat2px(a: LngLatXY) {
    const xy = proj4(this.projection)
      .forward(a)
      .map((t) => t / this.data.zoom);

    return [this.scaleVal * (xy[0] + this.imageWidth * 0.5), this.scaleVal * (this.imageHeight - xy[1])];
  }
  //像素转经纬度
  px2lnglat(a: LngLatXY) {
    const p = [
      (a[0] / this.scaleVal - this.imageWidth * 0.5) * this.data.zoom,
      (this.imageHeight - a[1] / this.scaleVal) * this.data.zoom
    ];
    return proj4(this.projection).inverse(p);
  }
  //设置图片大小
  setImageSize(w: number, h: number) {
    this.imageWidth = w;
    this.imageHeight = h;
  }
}

4.2 绘制底图

相比墨卡托投影的瓦片地图,这里只需直接使用ctx.drawImage根据底图缩放后的大小和移动位置绘制底图,十分方便

ts 复制代码
  async drawLayer() {
    if (this.isDrawLayer) return;
    this.isDrawLayer = true;
    this.renderer.clear();
    const ctx = this.ctx;
    ctx.drawImage(
      this.image!,
      //地图移动的位置
      this.move.offsetX,
      this.move.offsetY,
      //缩放后底图宽高
      this.projection.imageWidth * this.projection.scaleVal,
      this.projection.imageHeight * this.projection.scaleVal
    );
    this.isDrawLayer = false;
    this.isFirst = false;
    //绘制形状
    this.drawShape();
    //绘制html
    this.renderHtml();
  }

4.5 设置视图

在canvas上的经纬度和像素坐标转换

ts 复制代码
//经纬度转canvas上的像素坐标
  lnglat2Canvas(lnglat: LngLatXY): LngLatXY {
    const [x, y] = this.projection.lnglat2px(lnglat);
    return [x + this.move.offsetX, y + this.move.offsetY];
  }
  //canvas上的像素坐标转经纬度
  canvas2lnglat(xy: LngLatXY): LngLatXY {
    const p = this.projection.px2lnglat([xy[0] - this.move.offsetX, xy[1] - this.move.offsetY]);
    return p as LngLatXY;
  }

设置地图缩放大小和地图中心点位置

ts 复制代码
  setZoom(z: number) {
    if (z >= this.minZoom && z <= this.maxZoom) {
      this.zoom = z;

      this.projection.setScale(z * 0.1);
      this.setCenter(this.center);
    }
  }
  setCenter(center: LngLatXY) {
    if (center[0] >= -180 && center[0] <= 180 && center && center[1] >= -90 && center[1] <= 90) {
      this.center = center;
      const pos = this.projection.lnglat2px(center);
      this.move.offsetX = -pos[0] + this.canvas.width * 0.5;
      this.move.offsetY = -pos[1] + this.canvas.height * 0.5;
      this.drawLayer();
    }
  }

4.3 移动地图和缩放地图

监听鼠标按下记录起始坐标,监听鼠标移动计算移动距离,监听鼠标放开计算新的地图中心位置和绘制更新底图。

ts 复制代码
  onMouseDown(ev: MouseEvent) {
    this.move.enable = true;
    this.move.isMove = false;
    //鼠标开始位置
    this.move.startX = ev.pageX;
    this.move.startY = ev.pageY;
    //鼠标初始位置
    this.move.originX = ev.pageX;
    this.move.originY = ev.pageY;
  }
  onMouseMove(ev: MouseEvent) {
    if (this.move.enable) {
      //鼠标移动超过5px则为移动地图
      if (Math.abs(ev.pageX - this.move.originX) >= 5 || Math.abs(ev.pageY - this.move.originY) >= 5) {
        this.move.isMove = true;
      }
      //地图移动距离XY
      this.move.offsetX += ev.pageX - this.move.startX;
      this.move.offsetY += ev.pageY - this.move.startY;
      this.move.startX = ev.pageX;
      this.move.startY = ev.pageY;
    }
  }
  onMouseUp(ev: MouseEvent) {
    //移动地图
    if (this.move.enable && this.move.isMove) {
      //计算新的中心位置
      const newcenter = this.canvas2lnglat([this.canvas.width * 0.5, this.canvas.height * 0.5]);
      this.center = newcenter;
      this.drawLayer();
    } else {
      //点击地图
      this.clickMap(ev);
    }
    this.move.isMove = false;
    this.move.enable = false;
  }

监听滚轮动作,设置缩放大小

ts 复制代码
onWheel(ev: WheelEvent) {
    if (ev.deltaY > 0) {
      //down
      this.setZoom(this.zoom - this.zoomStep);
    } else {
      //up
      this.setZoom(this.zoom + this.zoomStep);
    }
  }

4.4 添加形状和HTML

用canvas绘制形状,渲染HTML元素,监听这些元素的动作跟我之前的文从零开始用Canvas画一个2D地图一致。

ts 复制代码
const resourcemap = new ResourceMap({
  container: document.getElementById('container')!,
  center: [116.407387, 39.904179],
  zoom: 2
});
resourcemap.init();
let index = 0;
//绘制中国边界
travelGeo(ChinaJson, (path: LngLatXY[], a: any) => {
  resourcemap.add({
    id: a.properties.name + ++index,
    type: 'Line',
    path: path,
    isClose: true,
    style: {
      lineColor: 'red',
      lineWidth: 3
    }
  });
});
//添加多边形
resourcemap.add({
  id: 'Polygon',
  type: 'Polygon',
  path: [
    [110, 39],
    [116, 39],
    [116, 30]
  ],
  style: {
    fillColor: 'blue',
    fillOpacity: 0.5,
    lineColor: 'blue',
    lineWidth: 3
  },
  isAction: true
});
//添加html
resourcemap.addHtml({
  type: 'html',
  content: `<div class="text-box"><div>Hello Map</div></div>`,
  pos: [116.3912757, 39.906217],
  id: 'textbox',
  isAction: true
});
//监听点击动作
resourcemap.events.on('click', ({ objs, lnglat, htmls }: any) => {
  console.log('click', objs, lnglat, htmls);
});

Github地址

https://github.com/xiaolidan00/demo-vite-ts

参考

相关推荐
星光不问赶路人14 小时前
TypeScript 模块扩展
vue.js·typescript
浩男孩16 小时前
🍀简简单单使用 TS 封装个工具库【更新中 ✍】
前端·typescript
兮漫天1 天前
bun + vite7 的结合,孕育的 Robot Admin 靓仔出道(五)
前端·typescript
萌萌哒草头将军1 天前
🚀🚀🚀 深入探索 Node.js v22.18.0 新特性;默认支持运行 ts 文件了!
前端·typescript·node.js
用户2519162427112 天前
Canvas之图形变换
前端·javascript·canvas
FogLetter2 天前
用Three.js打造炫酷3D地球:从入门到实现交互式体验
webgl·canvas
寅时码2 天前
我开源了一款 Canvas “瑞士军刀”,十几种“特效与工具”开箱即用
前端·开源·canvas
德育处主任3 天前
p5.js 用 beginGeometry () 和 endGeometry () 打造自定义 3D 模型
前端·前端框架·canvas