之前写了篇文《不用钱!纯前端打包下载离线瓦片地图》,爬取开放的瓦片底图,写入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
参考
- jszip-stuk.github.io/jszip/
- leaflet- github.com/Leaflet/Lea...
- proj4-proj4js.org/
- proj4leaflet-kartena.github.io/Proj4Leafle...
- 天地图-lbs.tianditu.gov.cn/server/MapS...
- 高德地图-lbs.amap.com/api/javascr...
- DataV.GeoAtlas地理小工具系列-datav.aliyun.com/portal/scho...
- 投影坐标系-epsg.io/
- OpenLayers-openlayers.org/