升级啦!纯前端打包下载离线地图

之前写了篇文《不用钱!纯前端打包下载离线瓦片地图》,爬取开放的瓦片底图,写入jszip压缩包,进行下载离线瓦片底图。

现在是时候升级一下了,总结了一下常用的一些地图底图下载。

1.坐标系

目前国内常用坐标系有

  • 国家大地坐标系CGCS2000(EPSG:4547)
  • 百度坐标系BD09
  • 火星坐标系GCJ02
  • 国际通用坐标系WGS84(EPSG:4326)

更多的地理坐标系可以到https://epsg.io/搜索,获取相关配置

EPSG地理坐标系一般可以使用proj4对坐标系进行转换,比如

国家大地坐标系CGCS2000与国际通用坐标系WGS84的相互转换

ts 复制代码
import proj4 from "proj4";
// CGCS2000地理坐标系
const cgcs2000 = "+proj=tmerc +lat_0=0 +lon_0=114 +k=1 +x_0=500000 +y_0=0 +ellps=GRS80 +units=m +no_defs";
// WGS84地理坐标系
const wgs84 = "+proj=longlat +datum=WGS84 +no_defs";
{
  //将CGCS2000坐标转换为WGS84坐标
  const [lng, lat] = proj4(cgcs2000, wgs84, [495741.2653999999, 2499953.2905]);
  console.log(lng, lat); //113.95858231900118 22.597395765246997
}
{
  // 将WGS84坐标转换为CGCS2000坐标
  const [lng, lat] = proj4(wgs84, cgcs2000, [113, 22]);
  console.log(lng, lat); //396734.05319196207 2434138.0838125898
}

百度坐标系BD09和火星坐标系GCJ02可以使用gcoord进行转换

ts 复制代码
import gcoord from "gcoord";
{
  //将WGS84坐标转换成BD09
  const [lng, lat] = gcoord.transform([113, 22], gcoord.WGS84, gcoord.BD09);
  console.log(lng, lat); //113.01173112892457 22.00318110953146
}
{
  //将WGS84坐标转换成GCJ02
  const [lng, lat] = gcoord.transform([113, 22], gcoord.WGS84, gcoord.GCJ02);
  console.log(lng, lat); //113.00519647627083 21.997261931400583
}

工具类 创建地图投影

ts 复制代码
 const {name, config, origin, resolutions} = projConfig;
  //定义投影
  proj4.defs(name, config);

  const scales: number[] = [];
  //lods配置
  if (resolutions?.length) {
    for (let i = resolutions.length - 1; i >= 0; i--) {
      if (resolutions[i]) {
        scales[i] = 1 / resolutions[i];
      }
    }
  }

坐标转换

ts 复制代码
//坐标偏移
    transformation: (function () {
      //原点位置偏移
      if (origin?.length) {
        return new Transformation(1, -origin[0], -1, origin[1]);
      }
      const scale = 0.5 / (Math.PI * EARTH_R);
      return new Transformation(scale, 0.5, -scale, 0.5);
    })(),
    //投影坐标
    project(lnglat: LngLatXY): LngLatXY {
      return proj4(name).forward(lnglat);
    },
    //逆投影坐标
    unproject(xy: LngLatXY): LngLatXY {
      return proj4(name).inverse(xy);
    },
    //经纬度转像素坐标
    lnglat2px(lnglat: LngLatXY, zoom: number): LngLatXY {
      const p = this.project(lnglat);
      const scale = this.scale(zoom);
      return this.transformation.transform(p, scale);
    },
    //像素坐标转经纬度
    px2lnglat(xy: LngLatXY, zoom: number): LngLatXY {
      const scale = this.scale(zoom);
      const p = this.transformation.untransform(xy, scale);
      return this.unproject(p);
    },

缩放等级和像素大小可以参考leaflet和proj4leaflet的代码,默认球面墨卡托投影。

计算该缩放等级的像素大小

ts 复制代码
scale(zoom: number) {
      //lods等级的像素大小
      if (resolutions?.length) {
        let iZoom = Math.floor(zoom),
          baseScale,
          nextScale,
          scaleDiff,
          zDiff;
        if (zoom === iZoom) {
          return scales[zoom];
        } else {
          // Non-integer zoom, interpolate
          baseScale = scales[iZoom];
          nextScale = scales[iZoom + 1];
          scaleDiff = nextScale - baseScale;
          zDiff = zoom - iZoom;
          return baseScale + scaleDiff * zDiff;
        }
      }
      return tileSize * Math.pow(2, zoom);
    }

计算该像素大小对应的缩放等级

ts 复制代码
    zoom(scale: number) {
      //lods缩放等级
      if (resolutions?.length) {
        // Find closest number in this._scales, down
        let downScale = closestElement(scales, scale);
        let downZoom = scales.indexOf(downScale!),
          nextScale,
          nextZoom,
          scaleDiff;
        // Check if scale is downScale => return array index
        if (scale === downScale) {
          return downZoom;
        }
        if (downScale === undefined) {
          return -Infinity;
        }
        // Interpolate
        nextZoom = downZoom + 1;
        nextScale = scales[nextZoom];
        if (nextScale === undefined) {
          return Infinity;
        }
        scaleDiff = nextScale - downScale;
        return (scale - downScale) / scaleDiff + downZoom;
      }
      return Math.log(scale / tileSize) / Math.LN2;
    },

2. 瓦片底图下载

2.1 手写canvas地图

获取瓦片的Image对象

ts 复制代码
 getTileImage(x: number, y: number, z: number) {
    return new Promise<HTMLImageElement | null>((resolve, reject) => {
      const id = `${x}-${y}-${z}`;
      //缓存瓦片底图
      if (this.cacheTiles[id] !== undefined) {
        resolve(this.cacheTiles[id]);
      } else {
        //加载瓦片底图
        const url = this.tileUrl
          .replace('{x}', String(x))
          .replace('{y}', String(y))
          .replace('{z}', String(z));
        const image = new Image();
        image.src = url;
        image.crossOrigin = 'anonymous';
        image.onload = () => {
          this.cacheTiles[id] = image;
          resolve(image);
        };
        image.onerror = () => {
          this.cacheTiles[id] = null;
          reject(image);
        };
      }
    });
  }

canvas绘制瓦片Image

ts 复制代码
async drawTileImage(
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number,
  z: number,
  imageX: number,
  imageY: number
) {
  try {
    const image = await this.getTileImage(x, y, z);
    if (image) {
      ctx.drawImage(image, imageX, imageY);
    }
  } catch (error) {}
}

计算瓦片范围与相关参数

ts 复制代码
getTileBounds(center?: LngLatXY, zoom?: number) {
    //中心经纬度转像素坐标
    const tileCenter = this.lnglat2xy(center ?? this.center, zoom ?? this.zoom);
    //canvas大小
    const mapSize = this.getMapSize();
    //取一半,获取左上点和右下点相对于中心点的像素坐标
    const halfWidth = mapSize[0] * 0.5;
    const halfHeight = mapSize[1] * 0.5;
    const start: LngLatXY = [tileCenter[0] - halfWidth, tileCenter[1] - halfHeight];
    const end: LngLatXY = [tileCenter[0] + halfWidth, tileCenter[1] + halfHeight];
    //瓦片底图是tileSize x tileSize大小的图片,计算瓦片范围
    const bounds = [
      [Math.floor(start[0] / this.tileSize), Math.floor(start[1] / this.tileSize)],
      [Math.ceil(end[0] / this.tileSize), Math.ceil(end[1] / this.tileSize)]
    ];
    return {
      tileCenter,
      bounds,
      start,
      end,
      //瓦片开始像素坐标相对canvas可视范围的左上点像素坐标偏移
      offset: [bounds[0][0] * this.tileSize - start[0], bounds[0][1] * this.tileSize - start[1]]
    };
  }

绘制瓦片底图

  • 计算地图可视范围,收集瓦片索引与位置
  • 排序绘制瓦片的顺序,优先绘制有缓存的瓦片
  • 触发6个并发请求加载图片并绘制到canvas上
ts 复制代码
async drawLayer() {
    const ctx = this.ctx;
    ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    const {offset, bounds, start, end, tileCenter} = this.getTileBounds();
    this.tileCenter = tileCenter;
    //开始像素坐标
    this.tileStart = start;
    //结束像素坐标
    this.tileEnd = end;
    //收集需要绘制的瓦片索引和瓦片在canvas上的位置
    const queue = [];
    for (let x = bounds[0][0], i = 0; x < bounds[1][0]; x++, i++) {
      for (let y = bounds[0][1], j = 0; y < bounds[1][1]; y++, j++) {
        queue.push({
          x,
          y,
          imageX: i * this.tileSize + offset[0],
          imageY: j * this.tileSize + offset[1]
        });
      }
    }
    //排序优先绘制有缓存的瓦片
    queue.sort((a: any, b: any) => {
      const id1 = `${a.x}-${a.y}-${this.zoom}`;
      const id2 = `${b.x}-${b.y}-${this.zoom}`;
      if (this.cacheTiles[id1]) return -1;
      if (this.cacheTiles[id2]) return 1;
      return 0;
    });
    //异步加载图片绘制到canvas上,http1.1的同一个域名下TCP并发连接数4-8个,通常6个。
    for (let i = 0; i < queue.length; i = i + 6) {
      const list = queue.slice(i, i + 6);
      await Promise.all(
        list.map((a) => this.drawTileImage(ctx, a.x, a.y, this.zoom, a.imageX, a.imageY))
      );
    }
    //绘制形状
    this.drawShape();
  }

以下地图瓦片采用的是天地图

另外可以根据需要设置自己需要的地图投影,如果使用了arcgis地图服务可以根据/MapServer?f=json获取tileInfo并配置原点origin、缩放层级lods、瓦片大小、最大最小缩放层级、投影坐标系等。投影坐标系配置可以到https://epsg.io/搜索。

以下是我的某个项目中国家大地坐标2000的arcgis地图服务配置

json 复制代码
"tileInfo": {
    "rows": 1024,//瓦片大小
    "cols": 1024,//瓦片大小
    "dpi": 96,
    "format": "PNG32",
    "compressionQuality": 0,
    "origin": {//原点
      "x": -5123200,
      "y": 10002100
    },
    "spatialReference": {//投影坐标系
      "wkid": 4547,
      "latestWkid": 4547
    },
    "lods": [//缩放层级
      {
        "level": 0,
        "resolution": 132.291931250529,
        "scale": 500000
      },
      {
        "level": 1,
        "resolution": 79.3751587503175,
        "scale": 300000
      },
      {
        "level": 2,
        "resolution": 66.1459656252646,
        "scale": 250000
      },    
    //...
    ]
  }, 

2.2 下载瓦片

框选范围后,获取某范围内要下载层级的瓦片地址与xyz

ts 复制代码
 getTileList(rect: [LngLatXY, LngLatXY], zoom: number) {
    const p1: LngLatXY = this.lnglat2xy(rect[0], zoom);
    const p2: LngLatXY = this.lnglat2xy(rect[1], zoom);
    const start = [Math.min(p1[0], p2[0]), Math.min(p1[1], p2[1])];
    const end = [Math.max(p1[0], p2[0]), Math.max(p1[1], p2[1])];
    // 计算瓦片范围
    const bounds = [
      [Math.floor(start[0] / this.tileSize), Math.floor(start[1] / this.tileSize)],
      [Math.ceil(end[0] / this.tileSize), Math.ceil(end[1] / this.tileSize)]
    ];

    const queue: any[] = [];
    for (let x = bounds[0][0], i = 0; x < bounds[1][0]; x++, i++) {
      for (let y = bounds[0][1], j = 0; y < bounds[1][1]; y++, j++) {
        const url = this.tileUrl
          .replace('{x}', String(x))
          .replace('{y}', String(y))
          .replace('{z}', String(zoom));
        queue.push({
          x,
          y,
          z: zoom,
          url
        });
      }
    }
    return queue;
  }

如果有需要下载大缩放层级的时候,瓦片数量过多,会出现溢出的情况,这时候则需要进行分包下载。

采用分包下载,需要关闭浏览器【下载前询问每个文件的保存位置】并许网页权限【自动下载项】

ts 复制代码
download: async () => {
      const {minLevel, maxLevel} = state.value;
      if (minLevel > maxLevel) {
        ElMessage.error('下载的最小等级必须小于等于最大等级!');
        return;
      }
      const b = state.value.bounds;
      if (checkBounds(b as [LngLatXY, LngLatXY])) {
        const queue: any[] = [];
        try {
          const rect = b as [LngLatXY, LngLatXY];
          for (let z = minLevel; z <= maxLevel; z++) {
            const q = map.getTileList(rect, z);
            queue.push(...q);
          }
        } catch (error) {
          ElMessage.error('数量太多,请分层级下载');
          return;
        }
        if (!window.confirm(`需下载${new Intl.NumberFormat().format(queue.length)}张瓦片底图,预计下载时间${getTime(queue.length * 0.5)}秒`))
          return;
        store.value.current = 0;
        store.value.total = queue.length;
        store.value.loading = true;
        //分包下载
        if (state.value.isSplit) {
          const n = state.value.spliteNum;
          for (let i = 0; i < queue.length; i = i + n) {
            const list = queue.slice(i, i + n);
            console.log(i, i + n, list.length);
            await downloadZip(list, i);
          }
        } else {
          await downloadZip(queue, 0);
        }
        store.value.loading = false;
      } else {
        ElMessage.error('请选择范围');
      }
    },

获取图片数据,并写入到zip包中。

ts 复制代码
export const downloadZip = (queue: any[], start: number) => {
  return new Promise(async (resolve) => {
    const {minLevel, maxLevel} = state.value;
    const zip = new JSZip();
    //异步加载图片绘制到canvas上,http1.1的同一个域名下TCP并发连接数4-8个,通常6个。
    for (let i = 0; i < queue.length; i += 6) {
      const list = queue.slice(i, i + 6);
      store.value.current = start + i;
      await Promise.all(list.map((a) => writeZip(zip, a.url, a.x, a.y, a.z)));
      await sleep();
    }

    zip
      .generateAsync({type: 'blob'})
      .then(function (content) {
        downloadFile(
          content,
          `瓦片层级[${minLevel}-${maxLevel}][${start}]${new Date().getTime()}.zip`
        );
      })
      .finally(() => {
        resolve(start);
      });
  });
};

下载的分包瓦片直接【解压到当前位置】即可,tiles文件夹下即为所有瓦片分包下载图片的总集

3. 区域边界图下载

获取行政区域边界,并绘制,获取行政区域范围

ts 复制代码
drawarea: (noFit: boolean) => {
      const code = state.value.currentArea;
      if (!code) {
        ElMessage.error('请选择区域');
        return;
      }
      const drawArea = (res: any) => {
        if (areaIds.length) {
          areaIds.forEach((id) => {
            map.removeShape(id);
          });
          areaIds = [];
        }
        let count = 0,id = 0;
        let lng = 0,lat = 0;
        const bounds = [[Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], [0, 0]] as [LngLatXY, LngLatXY];
        theGeojson = res;
        map.isGc = state.value.isGc;        
        travelGeo(res, (path: any) => {
          id++;
          const ps: any[] = [];
          path.forEach((a: any) => {
            const b = state.value.isGc ? a : gcTowgs84(a[0], a[1]);
            ps.push(b);
            count++;
            lng += b[0];
            lat += b[1];
            bounds[0][0] = Math.min(bounds[0][0], b[0]);
            bounds[0][1] = Math.min(bounds[0][1], b[1]);
            bounds[1][0] = Math.max(bounds[1][0], b[0]);
            bounds[1][1] = Math.max(bounds[1][1], b[1]);
          });
          const s = code + '_' + id;
          areaIds.push(s);
          map.addShape({
            id: s,
            type: 'polygon',
            path: ps,
            style: {
              stroke: true,
              color: 'blue',
              opacity: 1,
              weight: 2,
              fill: true,
              fillColor: 'blue',
              fillOpacity: 0.1
            }
          });
        });
        state.value.areaBounds = bounds;
        state.value.areaCenter = [lng / count, lat / count];
        if (!noFit) map.fitBounds({bounds, paddingLeft: 300});
        state.value.center = map.getCenter();
        state.value.zoom = map.getZoom();
        state.value.currentArea = code;
      };
      const id = `${code}${state.value.isFull ? '_full' : ''}`;
      if (cacheGeo[id]) {
        drawArea(cacheGeo[code]);
        return;
      }
      fetch(`https://geo.datav.aliyun.com/areas_v3/bound/${id}.json`)
        .then((res) => res.json())
        .then((res) => {
          cacheGeo[id] = res;
          drawArea(res);
        }).catch((err) => {
          ElMessage.error('获取区域边界失败,请重新选择');
        });
    }

地图瓦片来源于高德地图,区域边界数据来源于DataV.GeoAtlas地理小工具系列的geojson,该数据基于火星坐标系

截取行政区域内的canvas地图内容,下载该行政区域的地图

ts 复制代码
async drawAreaCanvas(geojson: any, rect: [LngLatXY, LngLatXY], zoom: number) {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d')!;
    const tileSize = this.tileSize;
    const {bounds, offset, start, end} = this.getTileInfo(rect, zoom);
    canvas.width = end[0] - start[0];
    canvas.height = end[1] - start[1];    
    //绘制遮罩
    const maskPath = new Path2D();
    travelGeo(geojson, (paths: Array<[number, number]>) => {
      const r = new Path2D();
      paths.forEach((a, index: number) => {
        const b = this.isGc ? a : gcTowgs84(a[0], a[1]);
        const p = this.lnglat2xy(b, zoom);
        const point = [p[0] - start[0], p[1] - start[1]];
        if (index === 0) r.moveTo(point[0], point[1]);
        else r.lineTo(point[0], point[1]);
      });
      r.closePath();
      maskPath.addPath(r);
    });
    const queue = [];
    for (let x = bounds[0][0], i = 0; x < bounds[1][0]; x++, i++) {
      for (let y = bounds[0][1], j = 0; y < bounds[1][1]; y++, j++) {
        queue.push({
          x,
          y,
          imageX: i * tileSize + offset[0],
          imageY: j * tileSize + offset[1]
        });
      }
    }
    //排序优先绘制有缓存的瓦片
    queue.sort((a: any, b: any) => {
      const id1 = `${a.x}-${a.y}-${zoom}`;
      const id2 = `${b.x}-${b.y}-${zoom}`;
      if (this.cacheTiles[id1]) return -1;
      if (this.cacheTiles[id2]) return 1;
      return 0;
    });
    //异步加载图片绘制到canvas上,http1.1的同一个域名下TCP并发连接数4-8个,通常6个。
    for (let i = 0; i < queue.length; i = i + 6) {
      const list = queue.slice(i, i + 6);
      await Promise.all(
        list.map((a) => this.drawTileImage(ctx, a.x, a.y, zoom, a.imageX, a.imageY))
      );
    }
    //截取行政区域内
    ctx.globalCompositeOperation = 'destination-in';
    ctx.fillStyle = '#000';   
    ctx.fill(maskPath);
    return {canvas, queue};
  }

注意: 因为浏览器中canvas大小限制,无法截取较大缩放层级的行政区域图。

4.下载行政区域瓦片图

1.基于上面的代码改一下

  • 将canvas的大小改成索引范围*tileSize
ts 复制代码
canvas.width = (bounds[1][0] - bounds[0][0]) * tileSize;
canvas.height = (bounds[1][1] - bounds[0][1]) * tileSize;
  • 绘制坐标修改一下
ts 复制代码
//截取行政区域的遮罩坐标,反向偏移
const point =[p[0] - start[0] - offset[0], p[1] - start[1] - offset[1]]

//瓦片索引与canvas上的坐标,不再需偏移
 queue.push({
          x,
          y,
          imageX: i * tileSize 
          imageY:j * tileSize  
        });
  • 关闭缓存瓦片排序,避免索引顺序混乱

2.根据索引坐标依次切割canvas成瓦片大小tileSize宽高的图片,并写入到zip包中

ts 复制代码
export const splitMapCanvas = (
  zip: JSZip,
  canvas: HTMLCanvasElement,
  zoom: number,
  tileSize: number,
  queue: Array<{x: number; y: number}>
) => {
  let idx = 0;
  for (let x = 0; x < canvas.width; x += tileSize) {
    for (let y = 0; y < canvas.height; y += tileSize) {
      const {x: x1, y: y1} = queue[idx];
      const tempCanvas = document.createElement('canvas');
      tempCanvas.width = tileSize;
      tempCanvas.height = tileSize;
      const tempctx = tempCanvas.getContext('2d')!;
      tempctx.drawImage(canvas, x, y, tileSize, tileSize, 0, 0, tileSize, tileSize);
      const base64 = tempCanvas.toDataURL('image/png');
      const file = convertBase64UrlToFile(base64, zoom + '.png');
      zip.file(`tiles/${zoom}/${y1}/${x1}.png`, file);
      idx++;
    }
  }
};

5.验证

可以绘制网格索引,方便验证下载的zip包中瓦片位置是否正确

也可以直接修改瓦片地址路径进行验证

当然也可以用leaflet或openlayer进行验证

ts 复制代码
//leaflet
const map = new L.map('container', {
            attributionControl: false,
            doubleClickZoom: false,
            preferCanvas: true,
        })
        map.setView([22.629045999999985, 114.0869626738281], 11)
        L.tileLayer(
            '/demo/tiles/{z}/{y}/{x}.png',
            {
                tileSize: 256,
            }
        ).addTo(map)
 
 //OpenLayers
 new ol.Map({
            target: 'map',
            layers: [
                new ol.layer.Tile({
                    source: new ol.source.XYZ({
                        url: '/demo/tiles/{z}/{y}/{x}.png',
                        tileSize: 256
                    }),

                })
            ],
            view: new ol.View({
                center: ol.proj.fromLonLat([114.0869626738281, 22.629045999999985]),
                zoom: 11
            })
        });

6.github地址和访问地址

https://github.com/xiaolidan00/offline-map-download

参考

相关推荐
SendTomo1 小时前
send.wang私传网:P2P直连传大文件首选工具
javascript·网络·网络协议·webrtc·p2p
晓得迷路了1 小时前
栗子前端技术周刊第 141 期 - Next.js 16.3、npm 安全事件、2026 CSS 现状调查报告结果...
前端·javascript·npm
To_OC10 小时前
别再瞎写 React Router!7 个高频踩坑点一次性讲透
前端·javascript·react.js
岭南灯火12 小时前
前端通用交互式几何编辑器的设计法则 3 - 数据对象的设计
前端·javascript·架构
岭南灯火12 小时前
前端通用交互式几何编辑器的设计法则 2 - 交互事件流
前端·javascript·架构
岭南灯火12 小时前
前端通用交互式几何编辑器的设计法则 1 - 入口的对象及其成员
前端·javascript·架构
满栀58513 小时前
Vue.js 接口封装最佳实践:从基础到高级
前端·javascript·vue.js
kyriewen14 小时前
我踩了三次同一个坑才明白:React里"改了数据却不重新渲染"的真正原因
前端·javascript·react.js
java1234_小锋14 小时前
Vue3专题 - 事件处理
javascript·vue.js