引言
高德地图默认使用GCJ02坐标系(火星坐标系),而许多国际标准地图服务(如OpenStreetMap)使用WGS84坐标系。本文介绍如何通过高德地图SDK实现自定义瓦片图层加载,并解决WGS84与GCJ02坐标系转换的偏移问题。
一、核心原理
1. 坐标系差异
-
GCJ02:中国国家测绘局制定的加密坐标系,高德地图原生支持。
-
WGS84:国际通用的地理坐标系,需转换为GCJ02才能与高德地图叠加。
2. 实现思路
-
下载WGS84瓦片数据。
-
将瓦片边界坐标从GCJ02反向转换为WGS84。
-
计算瓦片偏移量并拼接至Canvas。
-
通过高德
TileLayer.Flexible
实现自定义图层渲染。
二、代码实现步骤
1. 基础坐标转换函数定义
// GCJ-02 到 WGS-84 的坐标转换方法
function gcj02_To_wgs84(lng, lat) {
const coord = transform(lng, lat);
const lontitude = lng * 2 - coord.lng;
const latitude = lat * 2 - coord.lat;
return { lng: lontitude, lat: latitude };
}
// 坐标转换的辅助方法
function transform(lng, lat) {
const pi = 3.1415926535897932384626;
const a = 6378245.0;
const ee = 0.00669342162296594323;
let dLat = transformLat(lng - 105.0, lat - 35.0);
let dLng = transformLng(lng - 105.0, lat - 35.0);
const radLat = (lat / 180.0) * pi;
let magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * pi);
dLng = (dLng * 180.0) / (a / sqrtMagic * Math.cos(radLat) * pi);
const mgLat = lat + dLat;
const mgLng = lng + dLng;
return { lng: mgLng, lat: mgLat };
}
// 纬度转换的辅助方法
function transformLat(x, y) {
let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(y * Math.PI) + 40.0 * Math.sin(y / 3.0 * Math.PI)) * 2.0 / 3.0;
ret += (160.0 * Math.sin(y / 12.0 * Math.PI) + 320 * Math.sin(y * Math.PI / 30.0)) * 2.0 / 3.0;
return ret;
}
// 经度转换的辅助方法
function transformLng(x, y) {
let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(x * Math.PI) + 40.0 * Math.sin(x / 3.0 * Math.PI)) * 2.0 / 3.0;
ret += (150.0 * Math.sin(x / 12.0 * Math.PI) + 300.0 * Math.sin(x / 30.0 * Math.PI)) * 2.0 / 3.0;
return ret;
}
/**
* 将经纬度转换为瓦片坐标 (x, y)
* @param {number} lon - 经度
* @param {number} lat - 纬度
* @param {number} z - 瓦片层级 (缩放级别)
* @returns {Object} - 包含 x 和 y 坐标的对象
*/
function lonLatToTileXY(lon, lat, z) {
// 计算每层的瓦片总数
const n = Math.pow(2, z);
// 将经度转换为瓦片的 x 坐标
const x = Math.floor((lon + 180) / 360 * n);
// 将纬度转换为瓦片的 y 坐标
const latRad = lat * Math.PI / 180; // 将纬度转换为弧度
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
return { x, y };
}
/**
* 将经纬度转换为全局像素坐标
* @param {number} lon - 经度
* @param {number} lat - 纬度
* @param {number} z - 缩放级别
* @returns {Object} - 包含全局像素坐标 (xp, yp) 的对象
*/
function lonLatToGlobalPixelXY(lon, lat, z) {
const n = Math.pow(2, z) * 256; // 当前层级的全局像素范围
// 计算全局像素坐标 xp
const xp = Math.floor((lon + 180) / 360 * n);
// 计算全局像素坐标 yp
const latRad = lat * Math.PI / 180; // 纬度转换为弧度
const yp = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
return { xp, yp };
}
2、定义一个取图函数
function loadTile(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "Anonymous";
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load tile: ${url}`));
img.src = url;
});
}
4. 自定义瓦片图层
通过AMap.TileLayer.Flexible
实现动态加载:
var layer = new AMap.TileLayer.Flexible({
zIndex: 200,
createTile: function (x, y, z, success, fail) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 计算GCJ02瓦片边界并转换为WGS84
const bounds = getTileBounds(x, y, z);
const wgsTL = gcj02_To_wgs84(bounds.minLon, bounds.maxLat);
const wgsRD = gcj02_To_wgs84(bounds.maxLon, bounds.minLat);
// 下载并拼接所有相关的 WGS-84 瓦片
const promises = [];
for (let wgsX = tileTL.x; wgsX <= tileRD.x; wgsX++) {
for (let wgsY = tileTL.y; wgsY <= tileRD.y; wgsY++) {
const url = `https://xx/tile/${z}/${wgsX}/${wgsY}.png`;
promises.push(
loadTile(url)
.then(img => ({ img, wgsX, wgsY }))
.catch(() => null) // 忽略加载失败的瓦片
);
}
}
// 加载并拼接WGS84瓦片
Promise.all(tilePromises).then(results => {
results.forEach(({ img, wgsX, wgsY }) => {
// 计算偏移量并绘制到Canvas
// 计算当前 WGS-84 瓦片的范围
const wgsBounds = getTileBounds(wgsX, wgsY, z);
const tieCoord = gcj02_To_wgs84(bounds.minLon, bounds.maxLat);
let picTL = lonLatToGlobalPixelXY(wgsBounds.minLon, wgsBounds.maxLat, z);
let tileTL = lonLatToGlobalPixelXY(tieCoord.lng, tieCoord.lat, z);
let offsetX = tileTL.xp - picTL.xp;
let offsetY = tileTL.yp - picTL.yp;
ctx.drawImage(img, -offsetX, -offsetY, 256, 256);
});
success(canvas);
});
}
});
// 添加至地图
map.add(layer);
5、完整代码
以下代码直接复制使用,修改地图链接和高德key即可
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Custom Tile Layer with AMap</title>
<style>
#map-container {
width: 100%;
height: 600px;
}
</style>
</head>
<body>
<div id="map-container"></div>
<!-- 引入高德地图 SDK -->
<script src="https://webapi.amap.com/maps?v=2.0&key=xx"></script>
<script>
// 初始化地图
const map = new AMap.Map('map-container', {
zooms: [2,27], // 初始缩放级别
zoom: 18,
optimalZoom: 27,
center: [104.15970804286191, 30.67776629150997], // 初始中心点坐标(北京)
});
// 引入 WGS84 到 GCJ02 的转换库
// 可以使用 coordtransform 或其他开源库
function wgs84ToGcj02(lng, lat) {
// 这里使用 coordtransform 的转换逻辑
// 具体实现可以参考:https://github.com/wandergis/coordtransform
// 以下是简化的示例代码
const pi = 3.1415926535897932384626;
const a = 6378245.0;
const ee = 0.00669342162296594323;
function transformLat(x, y) {
let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * pi) + 20.0 * Math.sin(2.0 * x * pi)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(y * pi) + 40.0 * Math.sin(y / 3.0 * pi)) * 2.0 / 3.0;
ret += (160.0 * Math.sin(y / 12.0 * pi) + 320 * Math.sin(y * pi / 30.0)) * 2.0 / 3.0;
return ret;
}
function transformLon(x, y) {
let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * pi) + 20.0 * Math.sin(2.0 * x * pi)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(x * pi) + 40.0 * Math.sin(x / 3.0 * pi)) * 2.0 / 3.0;
ret += (150.0 * Math.sin(x / 12.0 * pi) + 300.0 * Math.sin(x / 30.0 * pi)) * 2.0 / 3.0;
return ret;
}
let dLat = transformLat(lng - 105.0, lat - 35.0);
let dLon = transformLon(lng - 105.0, lat - 35.0);
const radLat = lat / 180.0 * pi;
let magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * pi);
dLon = (dLon * 180.0) / (a / sqrtMagic * Math.cos(radLat) * pi);
const mgLat = lat + dLat;
const mgLon = lng + dLon;
return { lng: mgLon, lat: mgLat };
}
// 计算瓦片的范围
function getTileBounds(x, y, z) {
const n = Math.pow(2, z);
const lon1 = (x / n) * 360.0 - 180.0;
const lat1 = (Math.atan(Math.sinh(Math.PI * (1 - 2 * (y + 1) / n)))) * 180.0 / Math.PI;
const lon2 = ((x + 1) / n) * 360.0 - 180.0;
const lat2 = (Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n)))) * 180.0 / Math.PI;
return { minLon: lon1, minLat: lat1, maxLon: lon2, maxLat: lat2 };
}
// 下载瓦片
function loadTile(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "Anonymous";
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load tile: ${url}`));
img.src = url;
});
}
// GCJ-02 到 WGS-84 的坐标转换方法
function gcj02_To_wgs84(lng, lat) {
const coord = transform(lng, lat);
const lontitude = lng * 2 - coord.lng;
const latitude = lat * 2 - coord.lat;
return { lng: lontitude, lat: latitude };
}
// 坐标转换的辅助方法
function transform(lng, lat) {
const pi = 3.1415926535897932384626;
const a = 6378245.0;
const ee = 0.00669342162296594323;
let dLat = transformLat(lng - 105.0, lat - 35.0);
let dLng = transformLng(lng - 105.0, lat - 35.0);
const radLat = (lat / 180.0) * pi;
let magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * pi);
dLng = (dLng * 180.0) / (a / sqrtMagic * Math.cos(radLat) * pi);
const mgLat = lat + dLat;
const mgLng = lng + dLng;
return { lng: mgLng, lat: mgLat };
}
// 纬度转换的辅助方法
function transformLat(x, y) {
let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(y * Math.PI) + 40.0 * Math.sin(y / 3.0 * Math.PI)) * 2.0 / 3.0;
ret += (160.0 * Math.sin(y / 12.0 * Math.PI) + 320 * Math.sin(y * Math.PI / 30.0)) * 2.0 / 3.0;
return ret;
}
// 经度转换的辅助方法
function transformLng(x, y) {
let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(x * Math.PI) + 40.0 * Math.sin(x / 3.0 * Math.PI)) * 2.0 / 3.0;
ret += (150.0 * Math.sin(x / 12.0 * Math.PI) + 300.0 * Math.sin(x / 30.0 * Math.PI)) * 2.0 / 3.0;
return ret;
}
/**
* 将经纬度转换为瓦片坐标 (x, y)
* @param {number} lon - 经度
* @param {number} lat - 纬度
* @param {number} z - 瓦片层级 (缩放级别)
* @returns {Object} - 包含 x 和 y 坐标的对象
*/
function lonLatToTileXY(lon, lat, z) {
// 计算每层的瓦片总数
const n = Math.pow(2, z);
// 将经度转换为瓦片的 x 坐标
const x = Math.floor((lon + 180) / 360 * n);
// 将纬度转换为瓦片的 y 坐标
const latRad = lat * Math.PI / 180; // 将纬度转换为弧度
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
return { x, y };
}
/**
* 将经纬度转换为全局像素坐标
* @param {number} lon - 经度
* @param {number} lat - 纬度
* @param {number} z - 缩放级别
* @returns {Object} - 包含全局像素坐标 (xp, yp) 的对象
*/
function lonLatToGlobalPixelXY(lon, lat, z) {
const n = Math.pow(2, z) * 256; // 当前层级的全局像素范围
// 计算全局像素坐标 xp
const xp = Math.floor((lon + 180) / 360 * n);
// 计算全局像素坐标 yp
const latRad = lat * Math.PI / 180; // 纬度转换为弧度
const yp = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
return { xp, yp };
}
// 创建自定义瓦片图层
var layer = new AMap.TileLayer.Flexible({
cacheSize: 300,
zIndex: 200,
tileSize: 256,
dataZooms:[2, 27],
createTile: function (x, y, z, success, fail) {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 256;
const ctx = canvas.getContext('2d');
// 获取当前 GCJ-02 瓦片的范围
const bounds = getTileBounds(x, y, z);
// 将 GCJ-02 范围转换为 WGS-84 范围
const wgsTL = gcj02_To_wgs84(bounds.minLon, bounds.maxLat);
const wgsRD = gcj02_To_wgs84(bounds.maxLon, bounds.minLat);
// 计算 WGS-84 瓦片的 x, y 范围
let tileTL = lonLatToTileXY(wgsTL.lng, wgsTL.lat, z);
let tileRD = lonLatToTileXY(wgsRD.lng, wgsRD.lat, z);
// 下载并拼接所有相关的 WGS-84 瓦片
const promises = [];
for (let wgsX = tileTL.x; wgsX <= tileRD.x; wgsX++) {
for (let wgsY = tileTL.y; wgsY <= tileRD.y; wgsY++) {
const url = `https://xx/image/tile/${z}/${wgsX}/${wgsY}.png`;
promises.push(
loadTile(url)
.then(img => ({ img, wgsX, wgsY }))
.catch(() => null) // 忽略加载失败的瓦片
);
}
}
// 等待所有瓦片下载完成
Promise.all(promises).then(results => {
results.forEach(result => {
if (result) {
const { img, wgsX, wgsY } = result;
// 计算当前 WGS-84 瓦片的范围
const wgsBounds = getTileBounds(wgsX, wgsY, z);
const tieCoord = gcj02_To_wgs84(bounds.minLon, bounds.maxLat);
let picTL = lonLatToGlobalPixelXY(wgsBounds.minLon, wgsBounds.maxLat, z);
let tileTL = lonLatToGlobalPixelXY(tieCoord.lng, tieCoord.lat, z);
let offsetX = tileTL.xp - picTL.xp;
let offsetY = tileTL.yp - picTL.yp;
// 绘制瓦片到 Canvas 上
ctx.drawImage(img, -offsetX, -offsetY, 256, 256);
}
});
success(canvas);
}).catch(fail);
}
});
// 添加自定义瓦片图层
map.add(layer);
</script>
</body>
</html>
结语
本文实现了高德地图SDK加载WGS84坐标系瓦片的功能。开发者可基于此扩展自定义地图服务(如室内地图、专题图层)。完整代码已开源,欢迎在评论区交流讨论!
通过以上步骤,开发者可快速实现高德地图与WGS84坐标系的兼容,适用于需要混合多源地图数据的场景。