一、例子

QWebEngineView加载谷歌离线地图
QT_地图导航 源码下载
Graphics View实现简单离线地图
Qt QGraphics类应用——地图缩放选点
Qt QGraphics类应用——图片移动+选点缩放+控制移动区域
QGraphicsView 如何实现百度地图按照鼠标点进行放大缩小效果

二、项目

SELECT * FROM map WHERE TileColumn = 99 and TileRow = 46 and ZoomLevel = 7
DELETE FROM map WHERE TileColumn = 99 and TileRow = 46 and ZoomLevel = 7

1、/QtMapViewer_With_QGraphicsView-master/MapViewer

在线下载瓦片地图,QGraphicsView显示,可以成功运行的。

2、/MapGraphics-master/MapGraphics

在线加载瓦片地图,有方格线

瓦片数据缓存在:C:\Users\xxx\.MapGraphicsCache\OpenStreetMap Tiles

三、基础知识

1、TMS

TMS(Tile Map Service)、WMTS(Web Map Tile Service)瓦片服务
TMS瓦片
瓦片XY的计算
TMS下载文件的排列(TMS和google的区别)
wiki关于瓦片计算的代码
切图工具 MapTiler

2、切片坐标的计算:

(0)墨卡托投影

  • X轴:赤道半径为6378137米,则赤道周长为2πr = 20037508.3427892,因此X轴的取值范围:[-20037508.3427892, 20037508.3427892]。
  • y轴:本来纬度接近90度的时候,y轴是接近无限,但是为了在web上好计算,y轴的取值范围也定为了[-20037508.3427892, 20037508.3427892] 。
  • 由上面的x轴和y轴的取值范围,反计算得到经纬度的取值范围为:
    经度: [-180,180]
    纬度: [-85.05112877980659,85.05112877980659]
  • 瓦片个数
    层级0的瓦片数是1=2​0​​∗2​0
    层级1的瓦片数是4=2​1​​∗210
    层级n的瓦片数是2​n​​∗2​n
    如果只计算x轴或y轴一边的瓦片数,就是2n个。

(1)OSM(OpenStreetMap)计算:
①四叉树分割
n = 2zoom
Xtile = ((londeg + 180) / 360) * n
Ytile = (1 + log(tan(latrad) + sec(latrad)) / π) / 2 * n-1
Ytitle = (1 - log(tan(latrad) + 1/cos(latrad))/π) / 2 * n
②0级先二分,后四叉树分割
n = 2 zoom+1
Xtile = ((londeg + 180) / 360) * n
Ytile =((latdeg + 90) / 360) *n

(2)google瓦片X编码与tms一致,Y编码关系为:
Ygoogle= 2zoom - YTMS-1
YGoogle + YTMS = 2zoom - 1

google地图地址中的四个参数(x , y , z , s):
xy为切片坐标,z代表zoom,s =( 3x + y )%8
http://mt2.google.cn/vt/lyrs=m@167000000&hl=zh-CN&gl=cn&x=420&y=193&z=9&s=Galil

int long2tilex(double lon, int z) 
{ 
	return (int)(floor((lon + 180.0) / 360.0 * (1 << z))); 
}
int lat2tiley(double lat, int z)
{ 
    double latrad = lat * M_PI/180.0;
	return (int)(floor((1.0 - asinh(tan(latrad)) / M_PI) / 2.0 * (1 << z))); 
}
double tilex2long(int x, int z) 
{
	return x / (double)(1 << z) * 360.0 - 180;
}
double tiley2lat(int y, int z) 
{
	double n = M_PI - 2.0 * M_PI * y / (double)(1 << z);
	return 180.0 / M_PI * atan(0.5 * (exp(n) - exp(-n)));
}

(3)转换过程:经纬度 => 米 => 像素坐标 => 瓦片坐标
WebGIS 瓦片地图引擎实现之——地图瓦片加载计算原理介绍
在这里插入图片描述

①经纬度 => 米

const LatLongToMeterXY = (longitude: number, latitude: number) => {
  // 地球的周长的一半 20037508.342789244 单位米
  const circumferenceHalf = Math.PI * 2 * 6378137 / 2.0
  const mx = longitude * circumferenceHalf / 180
  const temp = Math.log(Math.tan((90 + latitude) * (Math.PI / 360.0))) / (Math.PI / 180.0)
  const my = circumferenceHalf * temp / 180  
  return { mx, my }
}
// [-20037508.342789244, -20037508.342789244, 20037508.342789244, 20037508.342789244]

②米 => 像素坐标

const MetersToPixelXY = (mx: number, my: number, zoom: number, tileSize = 256) => {
  // 地球的周长的一半 20037508.342789244 单位米
  const circumferenceHalf = Math.PI * 2 * 6378137 / 2.0  
  // 米/每像素
  const resolution = Math.PI * 2 * 6378137 / (tileSize * Math.pow(2, zoom))  
  const px = (mx + circumferenceHalf) / resolution
  const py = (my + circumferenceHalf) / resolution  
  return { px, py }
}

③像素坐标 => 瓦片坐标

const PixelXYToTileXY = (px: number, py: number, tileSize = 256) => {
  const tx = Math.floor(px / tileSize);
  const ty = Math.floor(py / tileSize);
  
  return { tx, ty }
}

(4)经纬度与瓦片坐标互转

①经纬度 => 瓦片

const LatLongToTileXY = (longitude: number, latitude: number, zoom: number) => {
  // 地球的周长的一半 20037508.342789244 单位米
  const circumferenceHalf = Math.PI * 6378137;
  const mx = (longitude * circumferenceHalf) / 180;
  const temp = Math.log(Math.tan((90 + latitude) * (Math.PI / 360.0))) / (Math.PI / 180.0);
  const my = (circumferenceHalf * temp) / 180;
  // 米/每像素
  const resolution = (Math.PI * 2 * 6378137) / (tileSize * Math.pow(2, zoom));
  
  // px = (mx + circumferenceHalf) / resolution
  const px = ((longitude + 180) / 360) * Math.pow(2, zoom) * tileSize;
  // py = (my + circumferenceHalf) / resolution
  const py = ((circumferenceHalf * temp) / 180 + circumferenceHalf) / (Math.PI * 2 * 6378137) / (tileSize * Math.pow(2, zoom));
  
  // tx = Math.floor(px / tileSize)
  const tx = Math.floor(((longitude + 180) / 360) * Math.pow(2, zoom));
  // ty = (1 - arsinh(tan(latitude * π / 180)) / π) * Math.pow(2, zoom)
  // 双曲正弦函数 arsinh(x) => ln(x + (x^2 + 1)^0.5)
  const ty = Math.floor(
    ((1 -
      Math.log(
        Math.tan((latitude * Math.PI) / 180) +
          1 / Math.cos((latitude * Math.PI) / 180)
      ) /
        Math.PI) /
      2) *
      Math.pow(2, zoom)
  );

  return { tx, ty };
};

② 瓦片 => 经纬度

const TileXYToLatLong = (tx: number, ty: number, zoom: number) => {
  const longitude = (x / Math.pow(2, zoom)) * 360 - 180;
  
  const tmp = Math.PI - (2 * Math.PI * y) / Math.pow(2, zoom);
  
  // latitude = (180 / Math.PI) * Math.atan(sinh(tmp))
  // 双曲函数sinh(x)=(e^x - e^(-x)) * 0.5
  const latitude =  (180 / Math.PI) * Math.atan(0.5 * (Math.exp(tmp) - Math.exp(-tmp)));

  return { longitude, latitude };
};

(5)屏幕点与经纬度转换

const qreal PI = 3.14159265358979323846;
const qreal deg2rad = PI / 180.0;
const qreal rad2deg = 180.0 / PI;
QPointF xxx::ll2qgs(const QPointF &ll, quint8 zoomLevel) const
{
    const qreal tilesOnOneEdge = pow(2.0,zoomLevel);
    const quint16 tileSize = this->tileSize();
    qreal x = (ll.x() + 180) * (tilesOnOneEdge * tileSize) / 360;            // coord to pixel!
    qreal y = (1 - (log(tan(PI/4  +(ll.y()*deg2rad)/2)) /PI)) /2  * (tilesOnOneEdge*tileSize);
    return QPoint(int(x), int(y));
}

QPointF xxx::qgs2ll(const QPointF &qgs, quint8 zoomLevel) const
{
    const qreal tilesOnOneEdge = pow(2.0,zoomLevel);
    const quint16 tileSize = this->tileSize();
    qreal longitude = (qgs.x() * (360 / (tilesOnOneEdge*tileSize))) - 180;
    qreal latitude = rad2deg*(atan(sinh((1-qgs.y()*(2/(tilesOnOneEdge*tileSize)))*PI)));
    return QPointF(longitude, latitude);
}

3、各大厂商瓦片编码规范

  • 谷歌XYZ:Z表示缩放层级,Z=zoom;XY的原点在左上角,X从左向右,Y从上向下。
  • TMS:开源产品的标准,Z的定义与谷歌相同;XY的原点在左下角,X从左向右,Y从下向上。
  • QuadTree:微软Bing地图使用的编码规范,Z的定义与谷歌相同,同一层级的瓦片不用XY两个维度表示,而只用一个整数表示,该整数服从四叉树编码规则
  • 百度XYZ:Z从1开始,在最高级就把地图分为四块瓦片;XY的原点在经度为0纬度为0的位置,X从左向右,Y从下向上。
  • 高德地图:遵循谷歌XYZ的编码规范,高德地图范围是85°N~85°S之间,x轴为85°N纬线,y轴为180°经线。

4、瓦片拼接

根据地理范围换算出瓦片行列号的原理(核心)
瓦片计算
C#拼接地图瓦片

(0)我的显示步骤(在拖动或首次需显示时)

  • 根据中心点经纬度、等级、窗口宽高,计算出参与屏幕显示的瓦片显示列表(适当扩出去1个或2个)
  • 根据“显示列表”,查询itemCashMap中是否存在对应Item,没有则生成QGraphicsPixmap(或自定义类),指定位置并加入到itemCashMap中,并将多余的Item移出scene
  • 根据中心点的视景坐标、屏幕大小,重新设置sceneRect

(1)流程

  1. 首先要确定一个要展示的地图的中心点,这个中心点随便找一个经纬度坐标即可。
  2. 根据中心点、cavans的宽高、resolutions、TileSize、FullExtent等来计算出当前cavans视图范围所对应的真实地理范围
  3. 计算瓦片的起始行列号以及x方向的瓦片个数、y方向的瓦片个数
  4. 计算扩展瓦片的真实地理范围位置
  5. 计算瓦片偏移量
  6. 根据偏移量、行列号、resolutions、TileSize计算每张瓦片在cavans中的像素坐标位置
  7. 根据瓦片个数循环遍历,拼接瓦片地址,请求瓦片,绘制到cavans中

下图中的2n为2n (n是level),r=6378137米,πr=20037508.3427892米

客户端要想渲染出地图有这么几个简单流程:

  • 获取当前地图的地理范围及当前地图的缩放层级
  • 通过上面两个参数计算出所有瓦片坐标(瓦片索引)
  • 通过瓦片的服务地址和瓦片坐标,拿到所有瓦片的数据(栅格数据或矢量数据,如果是栅格瓦片服务也就是图片)
  • 按顺序拼接好瓦片渲染出来

(2)显示瓦片

显示瓦片地图
用户平移地图的操作,就是改变地图中心点经纬度坐标,重新计算要显示的瓦片行列号,及第一张瓦片左上角相对于窗口的像素坐标值。

double tileSize = PGIS_TILE_WIDTH * pResolution;  //瓦片地理长度 256*resolution
double windowW = WScreen * pResolution;       //窗口地理长度
double windowH = HScreen * pResolution;
//窗口左上角地理偏移量
double windowOffsetW = WScreen/2 * pResolution; 
double windowOffsetH = HScreen/2 * pResolution;
//左上角 的地理坐标 (这样计算是假设窗口的中心点与显示的地图中心点重合)
double leftW  = cenlon - windowOffsetW; //经度 向左 越小
double leftH = (cenlat + windowOffsetH);  // 向上 是纬度越大 所以是加
//step1:第一张瓦片的行列号,
pFirstW = floor((leftW-maporiX)/tileSize);//向右经度变大,所以是终点经度减
pFirstH = floor((maporiY-leftH)/tileSize); //向下纬度变小,所以是起点纬度减
//窗口范围内可以显示tile的张数
Wcount = ceil(windowW/tileSize) + 1;
Hcount = ceil(windowH/tileSize) + 1;
//step2:最后一张tile的 行列号
pEndH = pFirstH + Hcount - 1;
pEndW = pFirstW + Wcount - 1 ;
//窗口左上角像素坐标(这里是以地图切割原点是0,0)  
double pixX = (leftW-maporiX)/pResolution;
double pixY = (maporiY - leftH)/pResolution; 
//step3:第一张tile左上角照片 偏移量(窗口左上角的像素坐标是0,0)
leftxTopDiffToCtrl = pixX  - pFirstW * PGIS_TILE_WIDTH;
leftyTopDiffToCtrl =  pixY  - pFirstH * PGIS_TILE_HEIGHT;

Web显示瓦片
公里数、经纬度、窗口坐标、Scene坐标、view坐标

  • 根据中心点经纬度、等级、窗口宽高,计算显示范围(公里数)
  • 计算左上角瓦片XY
  • 计算实际地理范围
  • 计算左上角偏移像素
  • 计算瓦片个数
  • 前端绘制瓦片
$(document).ready(function(){
    moveX = 0;
    moveY = 0;
    TitlesArry=[];
    //设置将要现实的地图中心点
    centerGeoPoint={
        x:116.337737,
        y:39.912465
    };
    centerGeoPoint=lonlatTomercator(centerGeoPoint);
    level = 5;
    //当前窗口显示的范围
    minX=centerGeoPoint.x-(MapConfig.Resolution[level]*MapConfig.ViewWidth/2);
    maxX=centerGeoPoint.x+(MapConfig.Resolution[level]*MapConfig.ViewWidth/2);
    minY=centerGeoPoint.y-(MapConfig.Resolution[level]*MapConfig.ViewHeight/2);
    maxY=centerGeoPoint.y+(MapConfig.Resolution[level]*MapConfig.ViewHeight/2);
    //左上角开始的行列号
    leftTopTitleRow = Math.floor(Math.abs(maxY-MapConfig.FullExtent.ymax)/MapConfig.Resolution[level]/MapConfig.TitlePix);
    leftTopTitleCol = Math.floor(Math.abs(minX-MapConfig.FullExtent.xmin)/MapConfig.Resolution[level]/MapConfig.TitlePix);
    //实际地理范围
    realMinX = MapConfig.FullExtent.xmin+leftTopTitleCol*MapConfig.TitlePix*MapConfig.Resolution[level];
    realMaxY = MapConfig.FullExtent.ymax-leftTopTitleRow*MapConfig.TitlePix*MapConfig.Resolution[level];
 
    //计算左上角偏移像素
    offSetX = (realMinX-minX)/MapConfig.Resolution[level];
    offSetY = (maxY-realMaxY)/MapConfig.Resolution[level];
    //计算瓦片个数
    xClipNum = Math.ceil((MapConfig.ViewHeight+Math.abs(offSetY))/MapConfig.TitlePix);
    yClipNum = Math.ceil((MapConfig.ViewWidth+Math.abs(offSetX))/MapConfig.TitlePix);
    //右下角行列号
    rightBottomTitleRow = leftTopTitleRow+xClipNum-1;
    rightBottomTitleCol = leftTopTitleCol+yClipNum-1;
    realMaxX = MapConfig.FullExtent.xmin+(rightBottomTitleCol+1)*MapConfig.TitlePix*MapConfig.Resolution[level];
    realMinY = MapConfig.FullExtent.ymax-(rightBottomTitleRow+1)*MapConfig.TitlePix*MapConfig.Resolution[level];
    var mapcv = document.getElementById("mapcv");
    var myctx = mapcv.getContext("2d");
    for(var i=0;i<xClipNum;i++){
        for(var j=0;j<yClipNum;j++){
            var beauty = new Image();
            beauty.src = MapConfig.RootDir+level+"/"+(leftTopTitleRow+i)+"/"+(leftTopTitleCol+j)+".jpg";
            var TitleImg={
                img:null,
                x:0,
                y:0
            };
            TitleImg.img=beauty;
            TitleImg.x=offSetX+(j*MapConfig.TitlePix);
            TitleImg.y=offSetY+(i*MapConfig.TitlePix);
            TitlesArry.push(TitleImg);
            myctx.drawImage(TitleImg.img, TitleImg.x, TitleImg.y);
        }
    }
});
function lonlatTomercator(lonlat) {
    var mercator={x:0,y:0};
    var x = lonlat.x *20037508.34/180;
    var y = Math.log(Math.tan((90+lonlat.y)*Math.PI/360))/(Math.PI/180);
    y = y *20037508.34/180;
    mercator.x = x;
    mercator.y = y;
    return mercator ;
}

根据地理范围换算出瓦片行列号的原理

5、Qt加载离线瓦片

(1)QGraphicsView方式

每一个瓦片表示为QGraphicsPixmapItem图形项,通过设置图形项在场景中位置来实现瓦片的拼接,从而展现一幅完整的地图。

对于离线瓦片文件的加载可通过两种方式实现:同步并行加载异步多线程加载

  • 同步并行加载,基于QtCocurrent并行计算框架。同步并行加载的优点是运行平台计算性能足够时能为用户提供极佳的地图漫游和缩放体验;缺点也相当明显,当运行平台计算性能不足时漫游和缩放可能出现卡顿。
  • 异步多线程加载,基于线程池+任务队列实现。运行平台计算性能没有过高的要求,不会出现界面卡顿现象;缺点是地图漫游和缩放时,可能出现轻微闪烁影响用户体验。

(2)瓦片文件加载方式

由于瓦片是按 /zoom/x/y.png 的目录结构组织瓦片数据,每次平移缩放加载数据时都要进行打开关闭文件的耗时操作,故可将所有瓦片打包到一个数据包中,这样只执行一次打开文件操作。

(3)不同处理方式

实时更新:

渐近更新:

替换更新:

平面视角:

3D视角:

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐